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,389 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { executable, runProcess } from "../proc.js";
|
|
4
|
+
import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
|
|
5
|
+
import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
|
|
6
|
+
import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
7
|
+
const defaultDeps = {
|
|
8
|
+
runCmd: (command, args, options) => runProcess(command, args, {
|
|
9
|
+
cwd: options.cwd,
|
|
10
|
+
timeoutMs: PROVISION_TIMEOUT_MS,
|
|
11
|
+
env: { ...process.env, ...options.env },
|
|
12
|
+
}),
|
|
13
|
+
};
|
|
14
|
+
// How much of a runner's output rides along in `detail`. Enough to see the
|
|
15
|
+
// stack that mattered, short enough that a stop message stays readable.
|
|
16
|
+
const DETAIL_LIMIT = 4_000;
|
|
17
|
+
// What each stack can and cannot tell us. Printed with the finding rather than
|
|
18
|
+
// kept quiet: promising all three stacks the same guarantee is exactly the kind
|
|
19
|
+
// of claim spec 32-5 had to go back and delete.
|
|
20
|
+
export const SIGNAL_STRENGTH = {
|
|
21
|
+
rspec: 'Full signal on this stack: the check loads the very file the suite starts from, ' +
|
|
22
|
+
'so whatever stops one stops the other.',
|
|
23
|
+
pytest: 'Partial signal on this stack: collection only imports what the tests import, so code no ' +
|
|
24
|
+
'test reaches was not checked — and it imports the whole test tree, so a failure may belong ' +
|
|
25
|
+
"to one of the project's own tests rather than to the suite that was about to be written.",
|
|
26
|
+
vitest: 'Weak signal on this stack: only test files are parsed and imported, and all of them — so a ' +
|
|
27
|
+
"failure may belong to one of the project's own tests. Type errors are not checked at all " +
|
|
28
|
+
'(vite and esbuild strip types without checking them), and a project with no tests yields ' +
|
|
29
|
+
'no signal.',
|
|
30
|
+
};
|
|
31
|
+
// Does the suite for this stack get off the ground? One attempt, one answer.
|
|
32
|
+
export async function bootCheck(projectRoot, runner, deps = defaultDeps) {
|
|
33
|
+
switch (runner) {
|
|
34
|
+
case 'rspec':
|
|
35
|
+
return rubyBootCheck(projectRoot, deps);
|
|
36
|
+
case 'pytest':
|
|
37
|
+
return pytestBootCheck(projectRoot, deps);
|
|
38
|
+
case 'vitest':
|
|
39
|
+
return vitestBootCheck(projectRoot, deps);
|
|
40
|
+
default:
|
|
41
|
+
return { status: 'not_checked', reason: 'no_runner' };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Ruby: load `.unitbob/structural/unitbob_helper.rb` under RAILS_ENV=test. That
|
|
45
|
+
// is not a stand-in for the suite's boot — it *is* the suite's boot. The
|
|
46
|
+
// generated spec's first line requires this exact file, and the helper hands off
|
|
47
|
+
// to the project's own `spec/rails_helper.rb` when there is one, so factories,
|
|
48
|
+
// `spec/support` and the project's own configuration all come along for free,
|
|
49
|
+
// without this module knowing anything about them.
|
|
50
|
+
async function rubyBootCheck(projectRoot, deps) {
|
|
51
|
+
const helper = join(projectRoot, GUARDRAILS_DIR, HELPER_FILE);
|
|
52
|
+
if (!existsSync(helper))
|
|
53
|
+
return { status: 'not_checked', reason: 'nothing_to_load' };
|
|
54
|
+
const first = await loadRubyHelper(projectRoot, helper, deps);
|
|
55
|
+
if (first.status !== 'broken')
|
|
56
|
+
return first;
|
|
57
|
+
// Repair what we are allowed to repair, then ask once more. A vibecoder has
|
|
58
|
+
// no test database — demanding one would turn away nearly every user we have.
|
|
59
|
+
// Never in a loop: one attempt, one retry, then the answer stands.
|
|
60
|
+
//
|
|
61
|
+
// Only when the failure is about the database, though. Preparing it is not
|
|
62
|
+
// free — it drops and reloads the schema, tens of seconds on a large app —
|
|
63
|
+
// and doing that in response to a syntax error is a side effect nobody asked
|
|
64
|
+
// for and a wait that buys nothing.
|
|
65
|
+
if (!looksLikeDatabase(first.detail))
|
|
66
|
+
return first;
|
|
67
|
+
if (!(await prepareTestDatabase(projectRoot, deps)))
|
|
68
|
+
return first;
|
|
69
|
+
return loadRubyHelper(projectRoot, helper, deps);
|
|
70
|
+
}
|
|
71
|
+
// Is this failure about the database at all? Kept as one broad signal rather
|
|
72
|
+
// than a list of adapter error classes — the same reason the cause heuristics
|
|
73
|
+
// stay generic. Being wrong here is cheap in one direction (a preparation we
|
|
74
|
+
// did not need) and cheap in the other (we return the failure we already have,
|
|
75
|
+
// which is honest either way).
|
|
76
|
+
function looksLikeDatabase(detail) {
|
|
77
|
+
return /database|migration|schema|ActiveRecord::(NoDatabase|StatementInvalid|PendingMigration)/i.test(detail);
|
|
78
|
+
}
|
|
79
|
+
async function loadRubyHelper(projectRoot, helper, deps) {
|
|
80
|
+
// `executable`, not `existsSync` — the same test `runRspecSuite` makes of
|
|
81
|
+
// `bin/rspec`, and for the same reason. A binstub that is present but not
|
|
82
|
+
// executable makes `spawn` throw, `attempt` return null, and the answer come
|
|
83
|
+
// back `no_runner`: "no runner available to load your suite with", said to
|
|
84
|
+
// someone whose bundler is installed and working. That is the mistake
|
|
85
|
+
// `runner_too_old` and `runner_could_not_answer` were added to stop making,
|
|
86
|
+
// and the global `bundle` was standing right there the whole time.
|
|
87
|
+
const localBundle = join(projectRoot, 'bin', 'bundle');
|
|
88
|
+
const command = executable(localBundle) ? localBundle : 'bundle';
|
|
89
|
+
return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
|
|
90
|
+
cwd: projectRoot,
|
|
91
|
+
env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
92
|
+
}),
|
|
93
|
+
// A clean load says nothing on stdout and exits 0. Anything else is the
|
|
94
|
+
// suite failing to start.
|
|
95
|
+
(result) => (result.code === 0 ? 'ok' : 'broken'));
|
|
96
|
+
}
|
|
97
|
+
// Python: `pytest --collect-only` imports every test module, which is what the
|
|
98
|
+
// run would do first. It sees import errors in code the tests reach, and only
|
|
99
|
+
// there — recorded as a limitation rather than dressed up as completeness.
|
|
100
|
+
//
|
|
101
|
+
// `-c` with an empty-addopts config, exactly as `runPytestSuite` does and for
|
|
102
|
+
// exactly its reason: the project's own `addopts` (`--cov`, `-n auto`) must not
|
|
103
|
+
// decide the answer. Without it a project whose `pytest.ini` asks for a plugin
|
|
104
|
+
// that is not installed came back `broken` — a healthy application refused over
|
|
105
|
+
// a `--cov` flag. The run path had already solved this; the check had not
|
|
106
|
+
// inherited the solution, which also made it *stricter* than the thing it
|
|
107
|
+
// predicts, the one rule this whole check is built on.
|
|
108
|
+
async function pytestBootCheck(projectRoot, deps) {
|
|
109
|
+
// Its own directory, rather than relying on `materializeHelper` having run
|
|
110
|
+
// first: this check must not fail because a different step was skipped.
|
|
111
|
+
mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
|
|
112
|
+
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
113
|
+
for (const python of ['python3', 'python']) {
|
|
114
|
+
const result = await attempt(deps, python, ['-m', 'pytest', '-c', PYTEST_INI_FILE, '--collect-only', '-q'], {
|
|
115
|
+
cwd: projectRoot,
|
|
116
|
+
});
|
|
117
|
+
if (result === null)
|
|
118
|
+
continue; // this interpreter is not on the machine
|
|
119
|
+
return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
|
|
120
|
+
}
|
|
121
|
+
return { status: 'not_checked', reason: 'no_runner' };
|
|
122
|
+
}
|
|
123
|
+
// pytest's own exit vocabulary, used rather than "zero or not". The distinction
|
|
124
|
+
// that matters is between "your code did not load" and "pytest itself could not
|
|
125
|
+
// be asked", and only the first is an answer about the project.
|
|
126
|
+
function pytestVerdict(code) {
|
|
127
|
+
// `classify` turns a timeout into `timed_out` before asking, so this is
|
|
128
|
+
// unreachable — but "no exit code" can only ever mean "we learned nothing".
|
|
129
|
+
if (code === null)
|
|
130
|
+
return 'runner_could_not_answer';
|
|
131
|
+
// 5 — collected nothing. A project that came to Unitbob *for* tests is the
|
|
132
|
+
// typical customer, not a defect.
|
|
133
|
+
if (code === 5)
|
|
134
|
+
return 'nothing_to_load';
|
|
135
|
+
// 3 — internal error, 4 — bad usage. Both are about the invocation, not the
|
|
136
|
+
// project, so neither may read as "your suite cannot start".
|
|
137
|
+
if (code === 3 || code === 4)
|
|
138
|
+
return 'runner_could_not_answer';
|
|
139
|
+
return code === 0 ? 'ok' : 'broken';
|
|
140
|
+
}
|
|
141
|
+
// JS/TS: `vitest list` parses and imports the test files, which is the closest
|
|
142
|
+
// thing this stack has to a boot.
|
|
143
|
+
//
|
|
144
|
+
// `tsc --noEmit` is deliberately not used. It answers a different question —
|
|
145
|
+
// are the types sound — and a project with a hundred type errors runs perfectly
|
|
146
|
+
// well, because vite, esbuild and tsx strip types without checking them. Type
|
|
147
|
+
// errors accumulate for years in healthy codebases; calling that "broken" would
|
|
148
|
+
// turn away the majority. A file that is genuinely unparseable is caught here
|
|
149
|
+
// anyway, since `vitest list` has to parse it.
|
|
150
|
+
async function vitestBootCheck(projectRoot, deps) {
|
|
151
|
+
const local = join(projectRoot, 'node_modules', '.bin', 'vitest');
|
|
152
|
+
// Only a vitest already installed in the project is used. Reaching for `npx`
|
|
153
|
+
// would install a package to answer a question, and installing into the
|
|
154
|
+
// user's project is not this check's business.
|
|
155
|
+
//
|
|
156
|
+
// `executable`, not `existsSync`, and there is no fallback to go to: an
|
|
157
|
+
// unrunnable binary sends `spawn` into EACCES, `supportsList` reads that as
|
|
158
|
+
// "cannot be asked", and the answer came back `runner_too_old` — a positive
|
|
159
|
+
// falsehood about a version nobody looked at. `no_runner` is the honest one
|
|
160
|
+
// here: there is no vitest this check can invoke.
|
|
161
|
+
if (!executable(local))
|
|
162
|
+
return { status: 'not_checked', reason: 'no_runner' };
|
|
163
|
+
// `list` is a subcommand only from Vitest 2.1. Older versions read it as a
|
|
164
|
+
// *filename filter* and go on to run whatever it matches, which was measured
|
|
165
|
+
// doing two unhelpful things: reporting "No test files found" on a project
|
|
166
|
+
// that plainly has tests, and — when a file happened to match — starting watch
|
|
167
|
+
// mode and hanging until the timeout killed it, two minutes for nothing.
|
|
168
|
+
//
|
|
169
|
+
// Neither is `broken`, so no healthy project was ever refused. But a check
|
|
170
|
+
// that quietly answers about the wrong thing is worse than one that says it
|
|
171
|
+
// did not run, so ask the version first and decline outright when the
|
|
172
|
+
// subcommand does not exist.
|
|
173
|
+
// Not `no_runner`: vitest is installed and works, it simply cannot be asked
|
|
174
|
+
// this question. Telling someone "no runner available" while it sits in their
|
|
175
|
+
// node_modules sends them to fix the wrong thing.
|
|
176
|
+
if (!(await supportsList(projectRoot, local, deps))) {
|
|
177
|
+
return { status: 'not_checked', reason: 'runner_too_old' };
|
|
178
|
+
}
|
|
179
|
+
const result = await attempt(deps, local, ['list'], { cwd: projectRoot });
|
|
180
|
+
return classify(projectRoot, 'vitest', result, (proc) => {
|
|
181
|
+
if (proc.code === 0)
|
|
182
|
+
return 'ok';
|
|
183
|
+
if (/no test files found/i.test(`${proc.stdout}\n${proc.stderr}`))
|
|
184
|
+
return 'nothing_to_load';
|
|
185
|
+
return 'broken';
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// The first Vitest that has a `list` subcommand. Below this it is a filter.
|
|
189
|
+
const VITEST_LIST_FROM = { major: 2, minor: 1 };
|
|
190
|
+
// `vitest --version` prints e.g. `vitest/1.6.0 darwin-arm64 node-v25.2.1`. A
|
|
191
|
+
// version we cannot read is treated as unsupported: guessing wrong here costs
|
|
192
|
+
// either a silent non-answer or a two-minute hang, and both are worse than
|
|
193
|
+
// saying plainly that the check did not run.
|
|
194
|
+
async function supportsList(projectRoot, binary, deps) {
|
|
195
|
+
const result = await attempt(deps, binary, ['--version'], { cwd: projectRoot });
|
|
196
|
+
if (!result || result.code !== 0)
|
|
197
|
+
return false;
|
|
198
|
+
const found = /vitest\/(\d+)\.(\d+)/i.exec(`${result.stdout}\n${result.stderr}`);
|
|
199
|
+
if (!found)
|
|
200
|
+
return false;
|
|
201
|
+
const [major, minor] = [Number(found[1]), Number(found[2])];
|
|
202
|
+
return major > VITEST_LIST_FROM.major || (major === VITEST_LIST_FROM.major && minor >= VITEST_LIST_FROM.minor);
|
|
203
|
+
}
|
|
204
|
+
// Runs one command, turning "this binary is not on the machine" into null (the
|
|
205
|
+
// caller decides whether that means `no_runner` or "try the next interpreter")
|
|
206
|
+
// and a timeout into a ProcResult with a null code, as runProcess already does.
|
|
207
|
+
async function attempt(deps, command, args, options) {
|
|
208
|
+
try {
|
|
209
|
+
return await deps.runCmd(command, args, options);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function classify(projectRoot, runner, result, verdict) {
|
|
216
|
+
if (result === null)
|
|
217
|
+
return { status: 'not_checked', reason: 'no_runner' };
|
|
218
|
+
// runProcess reports a timeout as a null exit code. Waiting too long tells us
|
|
219
|
+
// nothing about the code, so it must not read as a defect.
|
|
220
|
+
if (result.code === null)
|
|
221
|
+
return { status: 'not_checked', reason: 'timed_out' };
|
|
222
|
+
const outcome = verdict(result);
|
|
223
|
+
if (outcome === 'ok')
|
|
224
|
+
return { status: 'ok' };
|
|
225
|
+
if (outcome === 'nothing_to_load')
|
|
226
|
+
return { status: 'not_checked', reason: 'nothing_to_load' };
|
|
227
|
+
// Nothing was learned about the project, and saying otherwise would be the
|
|
228
|
+
// lie this check exists to remove.
|
|
229
|
+
if (outcome === 'runner_could_not_answer')
|
|
230
|
+
return { status: 'not_checked', reason: 'runner_could_not_answer' };
|
|
231
|
+
const output = `${result.stdout}\n${result.stderr}`.trim();
|
|
232
|
+
return {
|
|
233
|
+
status: 'broken',
|
|
234
|
+
cause: causeOf(output, projectRoot, runner),
|
|
235
|
+
message: firstErrorLine(output),
|
|
236
|
+
detail: output.length > DETAIL_LIMIT ? `${output.slice(0, DETAIL_LIMIT)}\n…` : output,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// One concept, not a catalogue of known errors — the lesson spec 32-2 drew from
|
|
240
|
+
// the over-fitted regex scanner of 32-3. "A name that does not resolve to a file
|
|
241
|
+
// inside this project" is the single class of failure that, in all three
|
|
242
|
+
// languages, reaches project code while still being a setup step rather than a
|
|
243
|
+
// defect.
|
|
244
|
+
const DEPENDENCY_MISSING = [
|
|
245
|
+
/\bLoadError\b/,
|
|
246
|
+
/Bundler::GemNotFound/,
|
|
247
|
+
/\bModuleNotFoundError\b/,
|
|
248
|
+
/Cannot find module\b/,
|
|
249
|
+
/Failed to resolve import\b/,
|
|
250
|
+
];
|
|
251
|
+
// Which of the two sentences the vibecoder reads. It changes wording only —
|
|
252
|
+
// both outcomes stop the run, because in both cases every test would die before
|
|
253
|
+
// asserting anything. That is why getting this wrong is cheap: the first draft
|
|
254
|
+
// of this spec let the same distinction decide whether to stop at all, and
|
|
255
|
+
// there a misclassification would have refused a healthy project.
|
|
256
|
+
function causeOf(output, projectRoot, runner) {
|
|
257
|
+
if (DEPENDENCY_MISSING.some((pattern) => pattern.test(output)))
|
|
258
|
+
return 'environment_not_ready';
|
|
259
|
+
return hasProjectFrame(output, projectRoot, runner) ? 'defect_in_code' : 'environment_not_ready';
|
|
260
|
+
}
|
|
261
|
+
// Both halves are needed, and reasoning from Rails alone hides that. Ruby
|
|
262
|
+
// resolves gems through `bundler/setup` before the first application file, so a
|
|
263
|
+
// missing gem there reliably produces a trace with no project frame in it.
|
|
264
|
+
// Python and JS have no such gate — the import is resolved from inside a project
|
|
265
|
+
// file:
|
|
266
|
+
//
|
|
267
|
+
// tests/test_foo.py:3: in <module>
|
|
268
|
+
// import requests
|
|
269
|
+
// E ModuleNotFoundError: No module named 'requests'
|
|
270
|
+
//
|
|
271
|
+
// There is a project frame, and it is an un-run `pip install`. Hence the second
|
|
272
|
+
// condition above.
|
|
273
|
+
function hasProjectFrame(output, projectRoot, runner) {
|
|
274
|
+
// Line by line, because a stack frame names one file and the question is
|
|
275
|
+
// asked of that file. Judging the whole output at once let `.venv/lib/...`
|
|
276
|
+
// answer yes on the strength of its `lib/`, which is how a `TypeError` deep
|
|
277
|
+
// inside a dependency came back as a defect in the user's code.
|
|
278
|
+
const ownDirs = runner === 'vitest' ? ['src'] : ['app', 'lib'];
|
|
279
|
+
const conventional = new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`);
|
|
280
|
+
for (const line of output.split('\n')) {
|
|
281
|
+
// Wherever a dependency is installed, it is not this project's code — and
|
|
282
|
+
// that has to be decided before anything below gets a chance to say yes.
|
|
283
|
+
if (INSTALLED_DEPENDENCY.test(line))
|
|
284
|
+
continue;
|
|
285
|
+
// The conventional homes of business code, relative or absolute.
|
|
286
|
+
if (conventional.test(line))
|
|
287
|
+
return true;
|
|
288
|
+
if (line.includes(projectRoot))
|
|
289
|
+
return true;
|
|
290
|
+
// Python names no fixed layout the way Rails does, and pytest prints frames
|
|
291
|
+
// relative to the working directory — `mypackage/billing.py:12`, matching
|
|
292
|
+
// neither of the two rules above. So fall back to the only thing that
|
|
293
|
+
// generalises: the file in this frame is a file this repository has.
|
|
294
|
+
//
|
|
295
|
+
// Data-driven, so it needs no list of package names and holds for
|
|
296
|
+
// src-layout, flat-layout and anything else. Without it a genuine
|
|
297
|
+
// `NameError` in the user's own module was reported as "your environment is
|
|
298
|
+
// not ready", sending them to run `pip install` over a typo.
|
|
299
|
+
for (const [, candidate] of line.matchAll(SOURCE_FRAME)) {
|
|
300
|
+
if (existsSync(join(projectRoot, candidate)))
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
// Where a dependency lives once installed — never the project's own code, in
|
|
307
|
+
// any of the three languages. The last two are the languages' own installed
|
|
308
|
+
// libraries: `…/lib/ruby/3.3.0/psych.rb` is a frame the `lib/` rule below would
|
|
309
|
+
// otherwise read as this project's business code.
|
|
310
|
+
const INSTALLED_DEPENDENCY = /site-packages|dist-packages|node_modules|[/.]venv[/\\]|\/gems\/|[/\\]lib[/\\]ruby[/\\]|[/\\]lib[/\\]python\d/;
|
|
311
|
+
// A `path/to/file.ext:LINE` frame, as every one of these runners prints them.
|
|
312
|
+
const SOURCE_FRAME = /([\w.\-/\\]+\.(?:py|rb|ts|tsx|js|jsx|mjs|cjs)):\d+/g;
|
|
313
|
+
// The runner's own words, never a paraphrase. A vibecoder can search for this
|
|
314
|
+
// string; a summary of it they cannot. File and line come along when the runner
|
|
315
|
+
// put them on the same line, and are not manufactured when it did not.
|
|
316
|
+
//
|
|
317
|
+
// Exported because spec 32-7 asks a second tool to load the application (the
|
|
318
|
+
// router), and one rule for quoting a failed load is better than two.
|
|
319
|
+
export function firstErrorLine(output) {
|
|
320
|
+
const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
321
|
+
const looksLikeError = /(^|[^A-Za-z])(error|exception|traceback)|:\d+:in |^E\s|\(.*Error\)/i;
|
|
322
|
+
return lines.find((line) => looksLikeError.test(line)) ?? lines[0] ?? 'the runner exited without output';
|
|
323
|
+
}
|
|
324
|
+
// The one repair this check performs. A test database is not a repository file
|
|
325
|
+
// and is disposable by nature, which puts it under the same rule that already
|
|
326
|
+
// lets `provision.ts` write its own Gemfile and venv: everything inside our own
|
|
327
|
+
// sandbox, nothing of the project's touched.
|
|
328
|
+
//
|
|
329
|
+
// The project's own dependencies (`bundle install`, `npm install`,
|
|
330
|
+
// `pip install`) stay off-limits for exactly that reason — they rewrite
|
|
331
|
+
// `Gemfile.lock` and `package-lock.json`, which are the user's files. Those are
|
|
332
|
+
// named to the human instead.
|
|
333
|
+
//
|
|
334
|
+
// Returns whether the repair ran, so the caller knows whether a retry is worth
|
|
335
|
+
// anything.
|
|
336
|
+
async function prepareTestDatabase(projectRoot, deps) {
|
|
337
|
+
if (!testDatabaseIsSeparate(projectRoot))
|
|
338
|
+
return false;
|
|
339
|
+
const rails = join(projectRoot, 'bin', 'rails');
|
|
340
|
+
const useBinstub = executable(rails);
|
|
341
|
+
const command = useBinstub ? rails : 'bundle';
|
|
342
|
+
const args = useBinstub ? ['db:test:prepare'] : ['exec', 'rails', 'db:test:prepare'];
|
|
343
|
+
const result = await attempt(deps, command, args, {
|
|
344
|
+
cwd: projectRoot,
|
|
345
|
+
env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
346
|
+
});
|
|
347
|
+
return result !== null && result.code === 0;
|
|
348
|
+
}
|
|
349
|
+
// Refuse to prepare unless we can positively read two different database names.
|
|
350
|
+
// Getting this wrong destroys a person's working data, which is not recoverable
|
|
351
|
+
// by re-running anything, so every uncertainty resolves to "don't": no
|
|
352
|
+
// database.yml, no `test:` block, a name we cannot read through ERB, or two
|
|
353
|
+
// names that match — all of them stop the step.
|
|
354
|
+
export function testDatabaseIsSeparate(projectRoot) {
|
|
355
|
+
const path = join(projectRoot, 'config', 'database.yml');
|
|
356
|
+
if (!existsSync(path))
|
|
357
|
+
return false;
|
|
358
|
+
let text;
|
|
359
|
+
try {
|
|
360
|
+
text = readFileSync(path, 'utf8');
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
const test = databaseNameIn(text, 'test');
|
|
366
|
+
const development = databaseNameIn(text, 'development');
|
|
367
|
+
if (!test || !development)
|
|
368
|
+
return false;
|
|
369
|
+
return test !== development;
|
|
370
|
+
}
|
|
371
|
+
// The `database:` of one top-level environment block. Deliberately textual: the
|
|
372
|
+
// connector carries no YAML parser, database.yml is routinely full of ERB, and
|
|
373
|
+
// a name inherited through a `<<: *default` anchor is one we cannot resolve —
|
|
374
|
+
// all of which come back as undefined and stop the step, which is the safe
|
|
375
|
+
// direction to be wrong in.
|
|
376
|
+
function databaseNameIn(text, environment) {
|
|
377
|
+
const lines = text.split('\n');
|
|
378
|
+
const start = lines.findIndex((line) => new RegExp(`^${environment}:\\s*(#.*)?$`).test(line));
|
|
379
|
+
if (start === -1)
|
|
380
|
+
return null;
|
|
381
|
+
for (const line of lines.slice(start + 1)) {
|
|
382
|
+
if (/^\S/.test(line))
|
|
383
|
+
break; // the next top-level block began
|
|
384
|
+
const match = /^\s+database:\s*(.+?)\s*$/.exec(line);
|
|
385
|
+
if (match)
|
|
386
|
+
return match[1];
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function selectRunnerEnvelope(candidates, runner) {
|
|
4
|
+
if (!runner || !Array.isArray(candidates))
|
|
5
|
+
return null;
|
|
6
|
+
const match = candidates.find((entry) => entry && typeof entry === 'object' && entry.runner === runner);
|
|
7
|
+
return match ? { ...match } : null;
|
|
8
|
+
}
|
|
9
|
+
// The behavioral envelope also carries the pinned dependency actually installed
|
|
10
|
+
// into the isolated runner environment. Only the machine that provisioned the
|
|
11
|
+
// sidecar can know it, so it is read back from that environment rather than
|
|
12
|
+
// guessed.
|
|
13
|
+
//
|
|
14
|
+
// Unreadable means no envelope at all — never a partial one, and never an
|
|
15
|
+
// invented version. The server requires this field on every behavioral upload,
|
|
16
|
+
// so an envelope missing it is rejected at the last step, after the suite has
|
|
17
|
+
// been written, run, and reviewed: precisely the failure this whole path exists
|
|
18
|
+
// to remove. An envelope is either complete and copied verbatim, or absent and
|
|
19
|
+
// the branch does not get built.
|
|
20
|
+
export function withInstalledRunnerVersion(envelope, runner, projectRoot) {
|
|
21
|
+
const version = installedRunnerVersion(runner, projectRoot);
|
|
22
|
+
return version ? { ...envelope, runner_version: version } : null;
|
|
23
|
+
}
|
|
24
|
+
const BEHAVIORAL_DIR = '.unitbob/behavioral';
|
|
25
|
+
// Matches the server's own PINNED_VERSION: a digit, then version characters.
|
|
26
|
+
const PINNED_VERSION = /^[0-9][0-9A-Za-z.\-+]*$/;
|
|
27
|
+
function installedRunnerVersion(runner, projectRoot) {
|
|
28
|
+
const root = join(projectRoot, BEHAVIORAL_DIR);
|
|
29
|
+
const found = readInstalledVersion(runner, root);
|
|
30
|
+
return found && PINNED_VERSION.test(found) ? found : null;
|
|
31
|
+
}
|
|
32
|
+
function readInstalledVersion(runner, root) {
|
|
33
|
+
switch (runner) {
|
|
34
|
+
case 'cucumber':
|
|
35
|
+
return gemfileLockVersion(join(root, 'Gemfile.lock'), 'cucumber');
|
|
36
|
+
case 'cucumber-js':
|
|
37
|
+
return packageJsonVersion(join(root, 'node_modules', '@cucumber', 'cucumber', 'package.json'));
|
|
38
|
+
case 'pytest-bdd':
|
|
39
|
+
return sitePackagesVersion(join(root, '.venv'), 'pytest_bdd');
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ` cucumber (9.2.0)` under GEM/specs. The indented, parenthesised form is
|
|
45
|
+
// the resolved version; the Gemfile's own `~> 9.0` constraint is not.
|
|
46
|
+
function gemfileLockVersion(path, gem) {
|
|
47
|
+
if (!existsSync(path))
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const match = readFileSync(path, 'utf8').match(new RegExp(`^\\s{4}${gem} \\(([^)]+)\\)$`, 'm'));
|
|
51
|
+
return match ? match[1] : null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function packageJsonVersion(path) {
|
|
58
|
+
if (!existsSync(path))
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
62
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Installed distributions record their version in the `<name>-<version>.dist-info`
|
|
69
|
+
// directory name, which needs no interpreter to read.
|
|
70
|
+
function sitePackagesVersion(venv, distribution) {
|
|
71
|
+
try {
|
|
72
|
+
for (const lib of readdirSync(join(venv, 'lib'))) {
|
|
73
|
+
const packages = join(venv, 'lib', lib, 'site-packages');
|
|
74
|
+
if (!existsSync(packages))
|
|
75
|
+
continue;
|
|
76
|
+
for (const entry of readdirSync(packages)) {
|
|
77
|
+
const match = entry.match(new RegExp(`^${distribution}-(.+)\\.dist-info$`));
|
|
78
|
+
if (match)
|
|
79
|
+
return match[1];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
package/dist/runner/precheck.js
CHANGED
|
@@ -5,11 +5,39 @@ const defaultDeps = {
|
|
|
5
5
|
commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
|
|
6
6
|
};
|
|
7
7
|
const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
|
|
8
|
+
// Tried in this order, so a project carrying markers for more than one stack
|
|
9
|
+
// resolves to the same runner on every run.
|
|
10
|
+
const STRUCTURAL_RUNNERS = ['rspec', 'vitest', 'pytest'];
|
|
11
|
+
// Which structural runner this project's markers select, or null when none do.
|
|
12
|
+
// The gate below walks the same list: "is any stack present" and "which one is
|
|
13
|
+
// it" must never be able to disagree.
|
|
14
|
+
export function detectStructuralRunner(projectRoot, deps = defaultDeps) {
|
|
15
|
+
return STRUCTURAL_RUNNERS.find((runner) => validateStack(projectRoot, runner, deps).ok) ?? null;
|
|
16
|
+
}
|
|
17
|
+
// The BDD runner for a structural stack. One project, one language: the
|
|
18
|
+
// behavioral peer follows the stack already detected instead of probing the
|
|
19
|
+
// filesystem a second time.
|
|
20
|
+
//
|
|
21
|
+
// A second probe used to answer first on `package.json` alone, so a Rails app
|
|
22
|
+
// with any front-end build — the common case — got an rspec structural branch
|
|
23
|
+
// and a cucumber-js behavioral one. Step definitions in JavaScript cannot boot
|
|
24
|
+
// Rails, use its test helpers, or reach its test database, so that branch was
|
|
25
|
+
// dead before it was written, and the generation recipe allows exactly one stack
|
|
26
|
+
// per project anyway.
|
|
27
|
+
const BDD_RUNNER_FOR_STACK = {
|
|
28
|
+
rspec: 'cucumber',
|
|
29
|
+
vitest: 'cucumber-js',
|
|
30
|
+
pytest: 'pytest-bdd',
|
|
31
|
+
};
|
|
32
|
+
export function detectBddRunner(projectRoot, deps = defaultDeps) {
|
|
33
|
+
const structural = detectStructuralRunner(projectRoot, deps);
|
|
34
|
+
return structural ? BDD_RUNNER_FOR_STACK[structural] ?? null : null;
|
|
35
|
+
}
|
|
8
36
|
// The generation-time gate: at least one supported stack must be present.
|
|
9
37
|
export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
|
|
10
|
-
const
|
|
11
|
-
if (
|
|
12
|
-
return { ok: true };
|
|
38
|
+
const runner = detectStructuralRunner(projectRoot, deps);
|
|
39
|
+
if (runner !== null)
|
|
40
|
+
return { ok: true, runner };
|
|
13
41
|
return {
|
|
14
42
|
ok: false,
|
|
15
43
|
message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
|
package/dist/runner/provision.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { runProcess } from "../proc.js";
|
|
4
|
+
// How long a local setup step may take before we stop waiting. Provisioning a
|
|
5
|
+
// runner and loading a cold Rails test environment sit in the same ballpark —
|
|
6
|
+
// tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
|
|
7
|
+
// number rather than inventing a second one to keep in sync.
|
|
8
|
+
export const PROVISION_TIMEOUT_MS = 120_000;
|
|
4
9
|
const defaultDeps = {
|
|
5
|
-
runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs:
|
|
10
|
+
runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs: PROVISION_TIMEOUT_MS, env: { ...process.env, ...options.env } }),
|
|
6
11
|
};
|
|
7
12
|
export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
|
|
8
13
|
const behavioralDir = join(projectRoot, '.unitbob', 'behavioral');
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
|
-
import { runProcess } from "../proc.js";
|
|
2
|
+
import { executable, runProcess } from "../proc.js";
|
|
4
3
|
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
5
4
|
import { readReport } from "./types.js";
|
|
6
5
|
export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -54,11 +53,3 @@ async function invokeRspec(projectRoot, rspecArgs) {
|
|
|
54
53
|
});
|
|
55
54
|
return { result, command, args };
|
|
56
55
|
}
|
|
57
|
-
function executable(path) {
|
|
58
|
-
try {
|
|
59
|
-
return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
}
|