unitbob 0.2.8 → 0.3.1
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
|
@@ -1,52 +1,76 @@
|
|
|
1
|
-
import { readBehavioralReview,
|
|
1
|
+
import { readBehavioralReview, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
+
import { collectBuildProblems, formatBranchProblems } from "./validateBuild.js";
|
|
2
3
|
import { Wire } from "../wire.js";
|
|
3
4
|
// Read the task and the host's answers, verify each branch parses and carries a
|
|
4
5
|
// safe-path artifact envelope, then upload both peer branches in one batch
|
|
5
6
|
// (spec 32). `source_digest` comes from the task — never the host's answer — so
|
|
6
7
|
// the host cannot claim a different map than each branch was given. A branch the
|
|
7
8
|
// host could not build is uploaded as a `build_error`, which never rolls back the
|
|
8
|
-
// peer branch.
|
|
9
|
+
// peer branch. A branch whose local review will not bind is reported unpublished
|
|
10
|
+
// and its peer still goes up — one branch's problem is never the other's.
|
|
11
|
+
//
|
|
12
|
+
// The line between "skip this branch" and "upload nothing" is whose problem it
|
|
13
|
+
// is. The answer *file* is the whole answer: missing, unparseable, or carrying
|
|
14
|
+
// no branches array, it stops everything, because there is no second problem to
|
|
15
|
+
// find. Everything smaller belongs to one branch — a malformed entry, a review
|
|
16
|
+
// that will not bind, a marker the local check could not account for — and its
|
|
17
|
+
// peer still goes up.
|
|
9
18
|
//
|
|
10
19
|
// Returns the server's per-branch results so the caller can compose the first run
|
|
11
20
|
// on top of them (spec 32-4) without parsing the lines printed here.
|
|
12
21
|
export async function putSuiteBuild(config, _args = [], deps) {
|
|
13
22
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
14
|
-
|
|
23
|
+
// Spec 32-6: read branch by branch, so one unreadable entry neither hides the
|
|
24
|
+
// next branch's problems nor sinks a peer that is finished and correct.
|
|
25
|
+
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
15
26
|
const d = {
|
|
16
27
|
putSuiteBuilds: (items) => new Wire(config).putSuiteBuilds(items),
|
|
17
28
|
stdout: process.stdout,
|
|
18
29
|
...deps,
|
|
19
30
|
};
|
|
20
31
|
const digestFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.source_digest]));
|
|
21
|
-
const items =
|
|
32
|
+
const items = [];
|
|
33
|
+
const blocked = unreadable.map((entry) => ({
|
|
34
|
+
suite_kind: entry.suite_kind,
|
|
35
|
+
status: BLOCKED_STATUS,
|
|
36
|
+
error: entry.message,
|
|
37
|
+
}));
|
|
38
|
+
// The same check `unitbob validate-build` runs, run here too so it cannot be
|
|
39
|
+
// skipped by going straight to the upload — but reported the way every other
|
|
40
|
+
// local failure here is reported: against the branch it belongs to.
|
|
41
|
+
//
|
|
42
|
+
// An earlier draft threw and stopped the command, which quietly undid spec
|
|
43
|
+
// 32-5 Phase 4: one missing marker in the behavioral answer would have left a
|
|
44
|
+
// finished structural suite unpublished. Every problem this check raises is
|
|
45
|
+
// already named against a branch, so it blocks that branch and never the
|
|
46
|
+
// batch. That also bounds what a false positive in a local check can cost —
|
|
47
|
+
// one branch, with the peer still going up and the server still the authority.
|
|
48
|
+
const problemsFor = new Map();
|
|
49
|
+
for (const problem of collectBuildProblems(request, outputs)) {
|
|
50
|
+
problemsFor.set(problem.branch, [...(problemsFor.get(problem.branch) ?? []), problem.message]);
|
|
51
|
+
}
|
|
52
|
+
for (const output of outputs) {
|
|
53
|
+
const failed = problemsFor.get(output.suite_kind);
|
|
54
|
+
if (failed) {
|
|
55
|
+
blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: formatBranchProblems(failed) });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
22
58
|
const sourceDigest = digestFor.get(output.suite_kind) ?? '';
|
|
23
59
|
if (output.build_error) {
|
|
24
|
-
|
|
60
|
+
items.push({ suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error });
|
|
61
|
+
continue;
|
|
25
62
|
}
|
|
26
63
|
let testMetadata = output.test_metadata;
|
|
27
64
|
if (output.suite_kind === 'behavioral') {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const qualityReview = review.bdd_quality_review;
|
|
31
|
-
if (!qualityReview || typeof qualityReview !== 'object') {
|
|
32
|
-
throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
|
|
65
|
+
try {
|
|
66
|
+
testMetadata = withReview(config, request, output);
|
|
33
67
|
}
|
|
34
|
-
|
|
35
|
-
|
|
68
|
+
catch (err) {
|
|
69
|
+
blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: err.message });
|
|
70
|
+
continue;
|
|
36
71
|
}
|
|
37
|
-
testMetadata = {
|
|
38
|
-
...output.test_metadata,
|
|
39
|
-
bdd_quality_review: {
|
|
40
|
-
...qualityReview,
|
|
41
|
-
candidate_digest: review.candidate_digest,
|
|
42
|
-
},
|
|
43
|
-
known_defect_probe: review.known_defect_probe,
|
|
44
|
-
known_defect_context: request.known_defect_context,
|
|
45
|
-
candidate_run: review.candidate_run,
|
|
46
|
-
...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
|
|
47
|
-
};
|
|
48
72
|
}
|
|
49
|
-
|
|
73
|
+
items.push({
|
|
50
74
|
suite_kind: output.suite_kind,
|
|
51
75
|
source_digest: sourceDigest,
|
|
52
76
|
artifacts: {
|
|
@@ -54,13 +78,53 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
54
78
|
runner_manifest: output.runner_manifest,
|
|
55
79
|
test_metadata: testMetadata,
|
|
56
80
|
},
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Every branch is blocked, so there is nothing to upload. Asking the server to
|
|
84
|
+
// publish an empty batch would turn a local, already-explained problem into a
|
|
85
|
+
// wire error with a worse message.
|
|
86
|
+
const results = items.length > 0 ? await d.putSuiteBuilds(items) : [];
|
|
87
|
+
const all = [...results, ...blocked];
|
|
88
|
+
for (const result of all) {
|
|
61
89
|
d.stdout.write(`${printResult(result)}\n`);
|
|
62
90
|
}
|
|
63
|
-
return
|
|
91
|
+
return all;
|
|
92
|
+
}
|
|
93
|
+
// A branch that cannot be assembled locally: its review is missing, stale, or
|
|
94
|
+
// malformed. Not a server status — it never reaches the server — but it travels
|
|
95
|
+
// as one so a single rule decides what counts as published (see `PUBLISHED`).
|
|
96
|
+
const BLOCKED_STATUS = 'not_ready';
|
|
97
|
+
// The behavioral branch's uploaded metadata, with the independent review and the
|
|
98
|
+
// connector's own run evidence folded in.
|
|
99
|
+
//
|
|
100
|
+
// Throws for anything that leaves this branch unpublishable — a missing review,
|
|
101
|
+
// one bound to a different candidate, a defect the review called not_supplied.
|
|
102
|
+
// The caller turns that into one unpublished branch rather than a failed
|
|
103
|
+
// command: a blocked review is a fact about the behavioral suite, and the
|
|
104
|
+
// structural peer next to it is finished and correct. Sinking the whole upload
|
|
105
|
+
// with it forced the one workaround this contract exists to prevent — hand-editing
|
|
106
|
+
// the answer down to a single branch, which loses the peer candidate for real.
|
|
107
|
+
function withReview(config, request, output) {
|
|
108
|
+
const review = readBehavioralReview(config.projectRoot, output);
|
|
109
|
+
const probe = review.known_defect_probe;
|
|
110
|
+
const qualityReview = review.bdd_quality_review;
|
|
111
|
+
if (!qualityReview || typeof qualityReview !== 'object') {
|
|
112
|
+
throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
|
|
113
|
+
}
|
|
114
|
+
if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
|
|
115
|
+
throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...output.test_metadata,
|
|
119
|
+
bdd_quality_review: {
|
|
120
|
+
...qualityReview,
|
|
121
|
+
candidate_digest: review.candidate_digest,
|
|
122
|
+
},
|
|
123
|
+
known_defect_probe: review.known_defect_probe,
|
|
124
|
+
known_defect_context: request.known_defect_context,
|
|
125
|
+
candidate_run: review.candidate_run,
|
|
126
|
+
...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
|
|
127
|
+
};
|
|
64
128
|
}
|
|
65
129
|
// The three outcomes that leave a branch published and current: a new version, an
|
|
66
130
|
// identical version already stored, or a reactivated one. Each returns the
|
|
@@ -90,7 +154,11 @@ export function classifyPublication(results) {
|
|
|
90
154
|
// above "no suite was published".
|
|
91
155
|
function printResult(result) {
|
|
92
156
|
if (!PUBLISHED.has(result.status)) {
|
|
93
|
-
|
|
157
|
+
// The reason often ends in a sentence of its own — a server message, or a
|
|
158
|
+
// list of this branch's problems — so the closing stop is added only when
|
|
159
|
+
// there is not one already.
|
|
160
|
+
const reason = unpublishedReason(result);
|
|
161
|
+
return `${result.suite_kind}: not published — ${reason}${/[.!?]$/.test(reason.trim()) ? '' : '.'}`;
|
|
94
162
|
}
|
|
95
163
|
const tallies = result.counts
|
|
96
164
|
? Object.entries(result.counts)
|
package/dist/verbs/run.js
CHANGED
|
@@ -5,6 +5,7 @@ import { runRspecSuite } from "../runner/rspec.js";
|
|
|
5
5
|
import { runVitestSuite } from "../runner/vitest.js";
|
|
6
6
|
import { runPytestSuite } from "../runner/pytest.js";
|
|
7
7
|
import { runBddSuite } from "../runner/bdd.js";
|
|
8
|
+
import { enterUrl } from "../links.js";
|
|
8
9
|
import { boundReport } from "../runner/boundReport.js";
|
|
9
10
|
import { Wire } from "../wire.js";
|
|
10
11
|
const OUTPUT_TAIL_CHARS = 2000;
|
|
@@ -54,8 +55,10 @@ async function execute(config, d, only) {
|
|
|
54
55
|
const { results, map_url } = await d.postRunsBatch(runs);
|
|
55
56
|
for (const result of results)
|
|
56
57
|
d.stdout.write(`${result.summary}\n`);
|
|
58
|
+
// The token joins the address here rather than on the server, so it stays out
|
|
59
|
+
// of response bodies and out of the brain's logs (spec 33).
|
|
57
60
|
if (map_url)
|
|
58
|
-
d.stdout.write(`${map_url}\n`);
|
|
61
|
+
d.stdout.write(`${enterUrl(config, map_url)}\n`);
|
|
59
62
|
}
|
|
60
63
|
// All-or-nothing. Publication and this fetch are two requests, so another client
|
|
61
64
|
// can republish in between. Running whatever is current instead would file honest
|
package/dist/verbs/show.js
CHANGED
|
@@ -1,10 +1,47 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
1
|
import { materializeHelper } from "../files/guardrails.js";
|
|
4
2
|
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
|
-
import {
|
|
3
|
+
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
4
|
+
import { anyStackPrecheck, detectBddRunner, detectStructuralRunner } from "../runner/precheck.js";
|
|
5
|
+
import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
|
|
6
6
|
import { ensureRunner } from "../runner/provision.js";
|
|
7
7
|
import { Wire } from "../wire.js";
|
|
8
|
+
// The complete envelope for one branch, or null when this machine cannot
|
|
9
|
+
// produce one: the server offered no combination this stack matches, or the
|
|
10
|
+
// behavioral runner's installed version could not be read back.
|
|
11
|
+
//
|
|
12
|
+
// Null stops the branch rather than handing the host a shape to fill in. The
|
|
13
|
+
// host's own composition is what this path replaced — a single wrong field is
|
|
14
|
+
// rejected at upload, hours after the suite was written, run, and reviewed —
|
|
15
|
+
// and a half-filled envelope fails the same way. Better to say so now, in one
|
|
16
|
+
// line, than to be told by the server at the end.
|
|
17
|
+
// Why a branch got no envelope, in the vibecoder's terms. The two causes sit on
|
|
18
|
+
// opposite sides of the wire and have opposite fixes, so they are never merged
|
|
19
|
+
// into one vague line.
|
|
20
|
+
function envelopeBlockedReason(packet, runner) {
|
|
21
|
+
if (!Array.isArray(packet.runner_manifests) || packet.runner_manifests.length === 0) {
|
|
22
|
+
return 'the Unitbob server sent no runner combinations with this assignment — it is older than this connector. ' +
|
|
23
|
+
'Update the server (or the connector) so the two agree, then retry.';
|
|
24
|
+
}
|
|
25
|
+
if (!runner) {
|
|
26
|
+
return `this project matches none of the runners the server offered for the ${packet.suite_kind} suite.`;
|
|
27
|
+
}
|
|
28
|
+
return `the version of "${runner}" installed under .unitbob/behavioral/ could not be read, and the server requires it. ` +
|
|
29
|
+
'Re-run `unitbob suite-prepare` so the runner is provisioned again.';
|
|
30
|
+
}
|
|
31
|
+
function runnerEnvelopeFor(packet, runner, projectRoot) {
|
|
32
|
+
const selected = runner
|
|
33
|
+
?? (packet.suite_kind === 'behavioral'
|
|
34
|
+
? detectBddRunner(projectRoot) ?? undefined
|
|
35
|
+
: detectStructuralRunner(projectRoot) ?? undefined);
|
|
36
|
+
const envelope = selectRunnerEnvelope(packet.runner_manifests, selected);
|
|
37
|
+
if (!envelope || !selected)
|
|
38
|
+
return envelope;
|
|
39
|
+
// Only the behavioral runner is provisioned into an isolated environment, so
|
|
40
|
+
// it is the only one with an installed version to record.
|
|
41
|
+
return packet.suite_kind === 'behavioral'
|
|
42
|
+
? withInstalledRunnerVersion(envelope, selected, projectRoot)
|
|
43
|
+
: envelope;
|
|
44
|
+
}
|
|
8
45
|
// Confirm at least one supported stack is present, materialize the Ruby boot
|
|
9
46
|
// helper a generated RSpec suite would require, then fetch both peer assignments
|
|
10
47
|
// (spec 32) and each branch's recipe, and write the host's task to
|
|
@@ -20,7 +57,9 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
20
57
|
getRecipe: (name) => wire.getRecipe(name),
|
|
21
58
|
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
22
59
|
precheck: anyStackPrecheck,
|
|
60
|
+
bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
|
|
23
61
|
ensureRunner: deps?.ensureRunner ?? ensureRunner,
|
|
62
|
+
runnerEnvelope: runnerEnvelopeFor,
|
|
24
63
|
stdout: process.stdout,
|
|
25
64
|
...deps,
|
|
26
65
|
};
|
|
@@ -28,6 +67,23 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
28
67
|
if (!check.ok)
|
|
29
68
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
30
69
|
materializeHelper(config.projectRoot);
|
|
70
|
+
// Spec 32-6. Before anything is fetched or written, find out whether the suite
|
|
71
|
+
// would get off the ground at all. It runs here, after the boot helper exists
|
|
72
|
+
// and before the network, so a project whose suite cannot start costs one
|
|
73
|
+
// command instead of a full generation.
|
|
74
|
+
//
|
|
75
|
+
// There is no `--on-broken-boot` flag and no mode. The decision is not a
|
|
76
|
+
// policy we could reasonably let a user set — it follows from the fact: we
|
|
77
|
+
// tried to load the thing the suite starts with, it did not load, therefore
|
|
78
|
+
// not one test would reach its first assertion. Debugging generation against a
|
|
79
|
+
// knowingly dead project is our problem, not the vibecoder's.
|
|
80
|
+
// The stack the precheck just identified, rather than a second detection of
|
|
81
|
+
// the same thing: on Python that would shell out to pytest all over again.
|
|
82
|
+
const structuralRunner = check.runner ?? null;
|
|
83
|
+
const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
|
|
84
|
+
if (boot.status === 'broken')
|
|
85
|
+
throw new Error(bootFinding(boot, structuralRunner));
|
|
86
|
+
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
31
87
|
const packets = await actual.getSuitePacketsBatch();
|
|
32
88
|
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
|
|
33
89
|
// A `fixable` outcome (no package manager available to install the runner) is an infrastructure
|
|
@@ -38,7 +94,8 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
38
94
|
const fixableNotices = [];
|
|
39
95
|
const buildable = [];
|
|
40
96
|
for (const packet of packets) {
|
|
41
|
-
const runner = packet.runner
|
|
97
|
+
const runner = packet.runner
|
|
98
|
+
?? (packet.suite_kind === 'behavioral' ? detectBddRunner(config.projectRoot) ?? undefined : undefined);
|
|
42
99
|
if (runner && (packet.suite_kind === 'behavioral' || ['cucumber', 'cucumber-js', 'pytest-bdd'].includes(runner))) {
|
|
43
100
|
const prov = await actual.ensureRunner(config.projectRoot, runner);
|
|
44
101
|
if (prov.status === 'fixable') {
|
|
@@ -47,15 +104,42 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
47
104
|
continue;
|
|
48
105
|
}
|
|
49
106
|
}
|
|
50
|
-
buildable.push(packet);
|
|
107
|
+
buildable.push({ packet, runner });
|
|
108
|
+
}
|
|
109
|
+
const prepared = await Promise.all(buildable.map(async ({ packet, runner }) => {
|
|
110
|
+
// Read after provisioning, never before: the behavioral envelope carries
|
|
111
|
+
// the version that is now installed in the sidecar.
|
|
112
|
+
const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
|
|
113
|
+
if (!manifest)
|
|
114
|
+
return { packet, runner, branch: null };
|
|
115
|
+
return {
|
|
116
|
+
packet,
|
|
117
|
+
runner,
|
|
118
|
+
branch: {
|
|
119
|
+
suite_kind: packet.suite_kind,
|
|
120
|
+
source_digest: packet.source_digest,
|
|
121
|
+
path_root: packet.path_root,
|
|
122
|
+
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
123
|
+
assignment: packet.assignment,
|
|
124
|
+
runner_manifest: manifest,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}));
|
|
128
|
+
// A branch without a complete envelope is not handed to the host at all. The
|
|
129
|
+
// host has nothing to compose it from — that composition is what this replaced
|
|
130
|
+
// — so writing the branch anyway only moves the same rejection to the end of
|
|
131
|
+
// the run, hours later.
|
|
132
|
+
const branches = [];
|
|
133
|
+
const blockedNotices = [];
|
|
134
|
+
for (const { packet, runner, branch } of prepared) {
|
|
135
|
+
if (branch)
|
|
136
|
+
branches.push(branch);
|
|
137
|
+
else
|
|
138
|
+
blockedNotices.push(` ${packet.suite_kind}: ${envelopeBlockedReason(packet, runner)}`);
|
|
139
|
+
}
|
|
140
|
+
if (branches.length === 0) {
|
|
141
|
+
throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
|
|
51
142
|
}
|
|
52
|
-
const branches = await Promise.all(buildable.map(async (packet) => ({
|
|
53
|
-
suite_kind: packet.suite_kind,
|
|
54
|
-
source_digest: packet.source_digest,
|
|
55
|
-
path_root: packet.path_root,
|
|
56
|
-
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
57
|
-
assignment: packet.assignment,
|
|
58
|
-
})));
|
|
59
143
|
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
60
144
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
61
145
|
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
@@ -73,7 +157,102 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
73
157
|
fixableNotices.join('\n') +
|
|
74
158
|
'\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
|
|
75
159
|
}
|
|
160
|
+
// Same shape, different cause: this branch has an assignment but no runner
|
|
161
|
+
// envelope to upload it with, so it is not offered to the host at all. Its
|
|
162
|
+
// peer above is unaffected.
|
|
163
|
+
if (blockedNotices.length > 0) {
|
|
164
|
+
actual.stdout.write('\nOne suite branch was left out of this run — it has no runner manifest, and the server ' +
|
|
165
|
+
'accepts an upload only with one:\n' +
|
|
166
|
+
blockedNotices.join('\n') +
|
|
167
|
+
'\n');
|
|
168
|
+
}
|
|
76
169
|
}
|
|
170
|
+
// What the boot check found, in the vibecoder's terms. Printed on every run,
|
|
171
|
+
// including the quiet ones: "we looked and it starts" and "we could not look"
|
|
172
|
+
// are both worth a line, and a check nobody hears about is a check nobody
|
|
173
|
+
// trusts.
|
|
174
|
+
//
|
|
175
|
+
// A stop here is a finding, not a refusal, and the wording has to carry that.
|
|
176
|
+
// "We found the defect that stops your suite from starting" and "we could not
|
|
177
|
+
// build your suite" describe the same event and leave the reader in completely
|
|
178
|
+
// different places.
|
|
179
|
+
function bootFinding(boot, runner) {
|
|
180
|
+
// Both halves of "what this answer is worth" travel together, on every
|
|
181
|
+
// outcome. Splitting them is how the stack caveat came to be missing from
|
|
182
|
+
// `broken`, and pinning `STRUCTURAL_ONLY` to `ok` alone would have repeated
|
|
183
|
+
// that in the same breath as the fix: on Rails the stack caveat reads
|
|
184
|
+
// "whatever stops one stops the other", which is an unscoped claim about a
|
|
185
|
+
// branch nobody asked — loudest exactly where the run stops for both.
|
|
186
|
+
// Empties are dropped rather than joined blindly, so a runner with no caveat
|
|
187
|
+
// of its own does not leave a blank line behind.
|
|
188
|
+
const caveat = [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
.map((line) => `\n${line}`)
|
|
191
|
+
.join('');
|
|
192
|
+
if (boot.status === 'ok') {
|
|
193
|
+
return `Checked that the suite can start: it does.${caveat}\n`;
|
|
194
|
+
}
|
|
195
|
+
if (boot.status === 'not_checked') {
|
|
196
|
+
// Not checked is not broken, and nothing downstream may treat it as such.
|
|
197
|
+
// Conflating the two would block honest projects — the whole reason this
|
|
198
|
+
// state is named for what happened rather than for what we know.
|
|
199
|
+
return `${NOT_CHECKED_REASON[boot.reason]} Generation continues.${caveat}\n`;
|
|
200
|
+
}
|
|
201
|
+
const headline = boot.cause === 'defect_in_code'
|
|
202
|
+
? 'Found a defect that stops your test suite from starting.'
|
|
203
|
+
: 'Your test suite cannot start yet — its environment is not ready.';
|
|
204
|
+
// The runner's own words. Everything else on screen is ours; this line is
|
|
205
|
+
// the one the vibecoder can paste into a search.
|
|
206
|
+
const next = boot.cause === 'defect_in_code'
|
|
207
|
+
? 'Fix that, then run `unitbob suite-prepare` again.'
|
|
208
|
+
: "Unitbob does not install your project's own dependencies — that would rewrite your Gemfile.lock " +
|
|
209
|
+
'or package-lock.json. Run the install your project needs (`bundle install`, `npm install`, ' +
|
|
210
|
+
'`pip install -r requirements.txt`), then run `unitbob suite-prepare` again.';
|
|
211
|
+
return (`${headline}\n\n` +
|
|
212
|
+
` ${boot.message}\n\n` +
|
|
213
|
+
`${boot.detail}\n\n` +
|
|
214
|
+
'No suite was written and nothing was uploaded — every test would have died on that line ' +
|
|
215
|
+
// The caveat belongs here most of all, and this was the one branch it did
|
|
216
|
+
// not reach — found on the fifth implementation review, 2026-08-03. On
|
|
217
|
+
// pytest and vitest the check collects the project's whole test tree, so
|
|
218
|
+
// the line above may come from a test of the project's own that the Unitbob
|
|
219
|
+
// suite would never have imported. Printing "found a defect" and keeping
|
|
220
|
+
// that back sends someone to fix a file this product was never going to
|
|
221
|
+
// touch, which is the same over-claim the spec accepted the wide check only
|
|
222
|
+
// on condition of disclosing.
|
|
223
|
+
`before reaching its first assertion. ${next}${caveat}\n`);
|
|
224
|
+
}
|
|
225
|
+
// Spec 32-6 says the boot rule is one rule for both branches; this check asks
|
|
226
|
+
// one of them. It is made against the *structural* runner, which is what
|
|
227
|
+
// `precheck` identified and what the materialized helper belongs to. The
|
|
228
|
+
// behavioral branch starts elsewhere — cucumber with its own `features/support`,
|
|
229
|
+
// cucumber-js with its own — and there is nothing of ours to load there yet:
|
|
230
|
+
// at this point in `suite-prepare` the behavioral suite has not been generated.
|
|
231
|
+
// Asking the question anyway would mean booting the project's own feature
|
|
232
|
+
// files, which is *wider* than the condition that stops the Unitbob run — the
|
|
233
|
+
// one thing this module's governing rule forbids ("the condition we test must
|
|
234
|
+
// equal the condition that makes a run impossible, never exceed it").
|
|
235
|
+
//
|
|
236
|
+
// So the boundary is stated instead of crossed. Recorded on the fifth
|
|
237
|
+
// implementation review, 2026-08-03, and written into the spec beside it.
|
|
238
|
+
const STRUCTURAL_ONLY = 'This says nothing about the product-behaviour branch: it starts with a runner of its own, ' +
|
|
239
|
+
'which has nothing of ours to load until its suite exists, so it was not asked.';
|
|
240
|
+
const NOT_CHECKED_REASON = {
|
|
241
|
+
no_runner: 'Did not check whether the suite can start: no runner available to load it with.',
|
|
242
|
+
// Distinct from `no_runner` on purpose. The runner is installed and working;
|
|
243
|
+
// it is only too old to be asked this particular question, and "no runner
|
|
244
|
+
// available" would send someone to fix a thing that is not broken.
|
|
245
|
+
runner_too_old: 'Did not check whether the suite can start: the installed runner is too old to be asked. ' +
|
|
246
|
+
'Nothing is wrong with it — this check simply has no way to pose the question to that version.',
|
|
247
|
+
// Distinct for the same reason, one step further along: the runner is there
|
|
248
|
+
// and current, it was reached, and it declined to answer — pytest exiting on
|
|
249
|
+
// a usage or internal error of its own. That says nothing about the project,
|
|
250
|
+
// and "no runner available" would again send someone after the wrong thing.
|
|
251
|
+
runner_could_not_answer: 'Did not check whether the suite can start: the runner could not answer the question — it ' +
|
|
252
|
+
'stopped on an error of its own before loading anything. Nothing was learned about your code either way.',
|
|
253
|
+
timed_out: 'Did not check whether the suite can start: loading it took too long and was stopped.',
|
|
254
|
+
nothing_to_load: 'Did not check whether the suite can start: there was nothing to load yet.',
|
|
255
|
+
};
|
|
77
256
|
function knownDefectContext(args) {
|
|
78
257
|
const defect = option(args, '--known-defect=');
|
|
79
258
|
const fixedRevision = option(args, '--fixed-revision=');
|
|
@@ -100,10 +279,3 @@ function option(args, prefix) {
|
|
|
100
279
|
throw new Error(`${prefix.slice(0, -1)} requires a value.`);
|
|
101
280
|
return value || undefined;
|
|
102
281
|
}
|
|
103
|
-
function inferBddRunner(projectRoot) {
|
|
104
|
-
if (existsSync(join(projectRoot, 'package.json')))
|
|
105
|
-
return 'cucumber-js';
|
|
106
|
-
if (['pyproject.toml', 'requirements.txt', 'Pipfile'].some((f) => existsSync(join(projectRoot, f))))
|
|
107
|
-
return 'pytest-bdd';
|
|
108
|
-
return 'cucumber';
|
|
109
|
-
}
|