unitbob 0.4.5 → 0.5.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/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +16 -1
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +20 -3
- package/dist/runner/bdd.js +75 -12
- package/dist/runner/bootcheck.js +47 -9
- package/dist/runner/precheck.js +104 -36
- package/dist/runner/provision.js +326 -26
- package/dist/runner/pytest.js +25 -20
- package/dist/runner/rspec.js +19 -11
- package/dist/runner/toolchain.js +122 -0
- package/dist/runner/vitest.js +64 -30
- package/dist/surfaces/routeInventory.js +15 -3
- package/dist/verbs/codexInstall.js +51 -16
- package/dist/verbs/putSuiteBuild.js +32 -75
- package/dist/verbs/run.js +15 -6
- package/dist/verbs/runLocal.js +19 -7
- package/dist/verbs/suitePrepare.js +84 -7
- package/dist/verbs/validateBuild.js +140 -456
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +9 -3
package/dist/files/guardrails.js
CHANGED
|
@@ -62,21 +62,31 @@ else
|
|
|
62
62
|
end
|
|
63
63
|
abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
|
|
64
64
|
`;
|
|
65
|
-
// Write the suite blob
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
65
|
+
// Write every file of the suite blob at its own (validated) relative path. The
|
|
66
|
+
// Ruby boot kit is materialized only for the rspec runner — Vitest and pytest
|
|
67
|
+
// runs need no connector-written support files here (the runtime pytest.ini
|
|
68
|
+
// lives outside this directory and is written by the pytest runner).
|
|
69
|
+
//
|
|
70
|
+
// Every file, not just the main one (spec 42, §6.4). The directory is wiped
|
|
71
|
+
// first and only the main file was written back, so a published suite of four
|
|
72
|
+
// files came back as one and the run that followed it silently protected a
|
|
73
|
+
// quarter of what the map claimed.
|
|
69
74
|
export function materializeGuardrails(projectRoot, suite) {
|
|
70
|
-
|
|
75
|
+
const files = [suite.suite_file, ...(suite.suite_file.support_files ?? [])];
|
|
76
|
+
for (const file of files)
|
|
77
|
+
assertGuardrailPath(file.path);
|
|
71
78
|
const dir = join(projectRoot, GUARDRAILS_DIR);
|
|
72
79
|
rmSync(dir, { recursive: true, force: true });
|
|
73
80
|
mkdirSync(dir, { recursive: true });
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
const written = files.map((file) => {
|
|
82
|
+
const path = join(projectRoot, file.path);
|
|
83
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
+
writeFileSync(path, file.content);
|
|
85
|
+
return path;
|
|
86
|
+
});
|
|
77
87
|
if (suite.runner_manifest.runner === 'rspec')
|
|
78
88
|
materializeHelper(projectRoot);
|
|
79
|
-
return { suitePath };
|
|
89
|
+
return { suitePath: written[0], supportPaths: written.slice(1) };
|
|
80
90
|
}
|
|
81
91
|
// Both Ruby flows boot the same way: the check flow writes the boot kit next to
|
|
82
92
|
// the suite here, the suite-build flow writes it right after the precheck.
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -82,12 +82,27 @@ export function branchRunner(output) {
|
|
|
82
82
|
}
|
|
83
83
|
return runner;
|
|
84
84
|
}
|
|
85
|
+
// What the reviewer actually read: the suite files, and the manifest that runs
|
|
86
|
+
// them. Nothing else (spec 42, §4).
|
|
87
|
+
//
|
|
88
|
+
// `test_metadata` used to be in here, and the server's copy of this formula
|
|
89
|
+
// stripped the review's own keys back out to match — two lists that had to stay
|
|
90
|
+
// identical for ever or every upload would break. The real cost was elsewhere,
|
|
91
|
+
// though: editing metadata declared the review stale. On noahsat-web,
|
|
92
|
+
// 2026-08-12 the reviewer was right that the steps drive only `PATCH`, the fix
|
|
93
|
+
// moved three `PUT` aliases into a deferred list, not one byte of the suite
|
|
94
|
+
// moved — and the run still paid for re-binding the candidate and a second
|
|
95
|
+
// reviewer pass, the most expensive step of the whole recipe, to satisfy the
|
|
96
|
+
// reviewer's own finding.
|
|
97
|
+
//
|
|
98
|
+
// `stableJson` sorts object keys and does nothing else. The server's
|
|
99
|
+
// `canonical_json` does the same, which is the only reason the two sides agree;
|
|
100
|
+
// a normalization added on one side alone would break every upload.
|
|
85
101
|
export function suiteCandidateDigest(output) {
|
|
86
102
|
return createHash('sha256')
|
|
87
103
|
.update(stableJson({
|
|
88
104
|
suite_file: output.suite_file,
|
|
89
105
|
runner_manifest: output.runner_manifest,
|
|
90
|
-
test_metadata: output.test_metadata,
|
|
91
106
|
}))
|
|
92
107
|
.digest('hex');
|
|
93
108
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readBehavioralReview } from "./suiteBuild.js";
|
|
2
|
+
// What travels to the server, and what "published" means when it answers. One
|
|
3
|
+
// module, because two commands ask those questions: `put-suite-build` sends the
|
|
4
|
+
// batch, and `validate-build` sends the same batch as a dry run so the server's
|
|
5
|
+
// verdict is about the exact bytes the publish will carry (spec 42, §3).
|
|
6
|
+
//
|
|
7
|
+
// A second assembly would be a second answer to "what are we uploading", and the
|
|
8
|
+
// dry run would then be checking something the publish does not send — which is
|
|
9
|
+
// worth less than not checking at all, because it reads as a verdict.
|
|
10
|
+
// The three outcomes that leave a branch published and current: a new version, an
|
|
11
|
+
// identical version already stored, or a reactivated one. Each returns the
|
|
12
|
+
// identity to run. Everything else — a rejected branch, a branch the host could
|
|
13
|
+
// not build, or a status this connector has never seen — fails closed and is
|
|
14
|
+
// never run, so a newer server can never trick an older connector into running
|
|
15
|
+
// something it does not understand.
|
|
16
|
+
export const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
|
|
17
|
+
// The answer a dry run gives to a branch it would accept. A server that does not
|
|
18
|
+
// know `dry_run` answers one of `PUBLISHED` instead — which means it published —
|
|
19
|
+
// and `validate-build` says so rather than reporting a check that passed.
|
|
20
|
+
export const WOULD_PUBLISH = 'would_publish';
|
|
21
|
+
// One branch, as the upload sends it. `source_digest` comes from the request,
|
|
22
|
+
// never from the host's answer, so the host cannot claim a different map than
|
|
23
|
+
// the branch was given.
|
|
24
|
+
export function uploadItem(request, output, testMetadata) {
|
|
25
|
+
const sourceDigest = request.branches.find((branch) => branch.suite_kind === output.suite_kind)?.source_digest ?? '';
|
|
26
|
+
if (output.build_error) {
|
|
27
|
+
return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
suite_kind: output.suite_kind,
|
|
31
|
+
source_digest: sourceDigest,
|
|
32
|
+
artifacts: {
|
|
33
|
+
suite_file: output.suite_file,
|
|
34
|
+
runner_manifest: output.runner_manifest,
|
|
35
|
+
test_metadata: testMetadata,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// The behavioral branch's uploaded metadata, with the independent review and the
|
|
40
|
+
// connector's own run evidence folded in.
|
|
41
|
+
//
|
|
42
|
+
// Throws for anything that leaves this branch unpublishable — a missing review,
|
|
43
|
+
// one bound to a different candidate, a defect the review called not_supplied.
|
|
44
|
+
// The caller turns that into one unpublished branch rather than a failed
|
|
45
|
+
// command: a blocked review is a fact about the behavioral suite, and the
|
|
46
|
+
// structural peer next to it is finished and correct. Sinking the whole upload
|
|
47
|
+
// with it forced the one workaround this contract exists to prevent — hand-editing
|
|
48
|
+
// the answer down to a single branch, which loses the peer candidate for real.
|
|
49
|
+
export function withReview(config, request, output) {
|
|
50
|
+
const review = readBehavioralReview(config.projectRoot, output);
|
|
51
|
+
const probe = review.known_defect_probe;
|
|
52
|
+
const qualityReview = review.bdd_quality_review;
|
|
53
|
+
if (!qualityReview || typeof qualityReview !== 'object') {
|
|
54
|
+
throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
|
|
55
|
+
}
|
|
56
|
+
if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
|
|
57
|
+
throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
...output.test_metadata,
|
|
61
|
+
bdd_quality_review: {
|
|
62
|
+
...qualityReview,
|
|
63
|
+
candidate_digest: review.candidate_digest,
|
|
64
|
+
},
|
|
65
|
+
...(review.selection_review ? { selection_review: review.selection_review } : {}),
|
|
66
|
+
known_defect_probe: review.known_defect_probe,
|
|
67
|
+
known_defect_context: request.known_defect_context,
|
|
68
|
+
candidate_run: review.candidate_run,
|
|
69
|
+
...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
4
5
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
6
|
export function workerPlanPath(projectRoot) {
|
|
6
7
|
return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
|
|
@@ -33,8 +34,13 @@ export function readWorkerPlan(projectRoot) {
|
|
|
33
34
|
}
|
|
34
35
|
return parsed;
|
|
35
36
|
}
|
|
37
|
+
const RUBY_HARNESS = {
|
|
38
|
+
behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
|
|
39
|
+
structural: '.unitbob/structural/unitbob_helper.rb',
|
|
40
|
+
};
|
|
36
41
|
export function validateWorkerPlanFiles(projectRoot) {
|
|
37
42
|
const errors = [];
|
|
43
|
+
const rubyProject = detectStructuralRunner(projectRoot) === 'rspec';
|
|
38
44
|
const plan = readWorkerPlan(projectRoot);
|
|
39
45
|
let request;
|
|
40
46
|
try {
|
|
@@ -86,12 +92,23 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
86
92
|
errors.push(`${label}: done_when must be non-empty`);
|
|
87
93
|
if (!isNonEmptyString(item?.harness_path))
|
|
88
94
|
errors.push(`${label}: harness_path must be non-empty`);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
// Both connector-owned harness files are Ruby, and only a Ruby project has
|
|
96
|
+
// them: `unitbob_helper.rb` boots Rails for RSpec, and the behavioral World
|
|
97
|
+
// is materialized for cucumber alone. Demanding them everywhere refused
|
|
98
|
+
// every Python and JS plan over a file that does not exist and would mean
|
|
99
|
+
// nothing if it did — the same mistake as the stack gate that reported a
|
|
100
|
+
// Python project as no stack at all. Found 2026-08-12.
|
|
101
|
+
//
|
|
102
|
+
// What is required of the other stacks is what the rule was ever about: the
|
|
103
|
+
// harness is connector territory, under `.unitbob/`, not a file in the
|
|
104
|
+
// project.
|
|
105
|
+
const expectedHarness = rubyProject ? RUBY_HARNESS[item?.branch] ?? null : null;
|
|
92
106
|
if (expectedHarness && item.harness_path !== expectedHarness) {
|
|
93
107
|
errors.push(`${label}: harness_path must name the connector-owned ${expectedHarness}`);
|
|
94
108
|
}
|
|
109
|
+
if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
|
|
110
|
+
errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
|
|
111
|
+
}
|
|
95
112
|
if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
|
|
96
113
|
errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
|
|
97
114
|
}
|
package/dist/runner/bdd.js
CHANGED
|
@@ -22,33 +22,96 @@ export const BDD_RUN_ARTIFACTS = [
|
|
|
22
22
|
PYTEST_BDD_PLUGIN_NAME,
|
|
23
23
|
PYTEST_INI_NAME,
|
|
24
24
|
];
|
|
25
|
+
// One name for the directory every strategy points its loader at, so the
|
|
26
|
+
// descriptors below and the commands below them cannot come to mean different
|
|
27
|
+
// directories.
|
|
28
|
+
const STEP_DEFINITIONS = 'step_definitions';
|
|
25
29
|
const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
|
|
26
30
|
const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
|
|
27
31
|
const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
|
|
28
32
|
const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
|
|
29
33
|
const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
34
|
+
// Load order is a fact about both Cucumbers and about neither pytest — pytest
|
|
35
|
+
// picks `conftest.py` up itself, so there is no trap to work around there.
|
|
36
|
+
const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file is not special to the runner — `account_access` ' +
|
|
37
|
+
'loads before `shared`. Open each capability file with an explicit require of the shared one rather ' +
|
|
38
|
+
'than trusting the alphabet.';
|
|
30
39
|
// The connector-owned BDD strategy table (spec 32): the `runner` enum names one
|
|
31
40
|
// of these; the connector never executes a host-provided command string. Each
|
|
32
41
|
// strategy runs the whole behavioral bundle and returns the raw machine-readable
|
|
33
42
|
// report verbatim — the connector does no marker join and no aggregation.
|
|
43
|
+
//
|
|
44
|
+
// A strategy is its command *and* its loading rule. They are one entry so that a
|
|
45
|
+
// fourth runner cannot be added with only half of itself stated.
|
|
46
|
+
const BDD_STRATEGIES = {
|
|
47
|
+
cucumber: {
|
|
48
|
+
run: (projectRoot) => runCucumberRuby(projectRoot),
|
|
49
|
+
loading: {
|
|
50
|
+
step_files: '*.rb',
|
|
51
|
+
requirements: [
|
|
52
|
+
'The connector points `--require` at `step_definitions/`, so every `.rb` file there is loaded. ' +
|
|
53
|
+
'That explicit `--require` also switches off Cucumber\'s automatic loading of `features/support/`: ' +
|
|
54
|
+
'a World or helper parked there is never evaluated, and every step then fails on a bare object.',
|
|
55
|
+
CUCUMBER_LOAD_ORDER,
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
'cucumber-js': {
|
|
60
|
+
run: (projectRoot) => runCucumberJs(projectRoot),
|
|
61
|
+
loading: {
|
|
62
|
+
step_files: '*.js',
|
|
63
|
+
requirements: [
|
|
64
|
+
'Keep `step_definitions/` to CommonJS JavaScript and nothing else. The connector passes the whole ' +
|
|
65
|
+
'directory to `--require`, and cucumber-js `require()`s every file it matches whatever the ' +
|
|
66
|
+
'extension — a stray `.ts`, `.json` or `.md` left there is executed as JavaScript and aborts the ' +
|
|
67
|
+
'entire run with a parse error, before a single scenario.',
|
|
68
|
+
'The connector registers no TypeScript loader, so a `.ts` file cannot compile itself. If you want ' +
|
|
69
|
+
'one, register the compiler from the file that sorts first — and remember the file registering it ' +
|
|
70
|
+
'is itself loaded as plain JavaScript.',
|
|
71
|
+
CUCUMBER_LOAD_ORDER,
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
'pytest-bdd': {
|
|
76
|
+
run: (projectRoot, mainPath) => runPytestBdd(projectRoot, mainPath),
|
|
77
|
+
loading: {
|
|
78
|
+
step_files: 'test_*.py',
|
|
79
|
+
requirements: [
|
|
80
|
+
'pytest collects `step_definitions/` under its own default, which is `test_*.py` and `*_test.py`. ' +
|
|
81
|
+
'The connector writes no `python_files` setting and will not: a file named outside those two — ' +
|
|
82
|
+
'`<capability>_steps.py`, say — is simply not collected. No error, no scenarios, a green run ' +
|
|
83
|
+
'over nothing.',
|
|
84
|
+
'`conftest.py` is picked up by pytest itself whatever else sits beside it, so shared fixtures ' +
|
|
85
|
+
'belong there and there is no load-order trap to work around.',
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
34
90
|
export function runBddSuite(projectRoot, runner, mainPath) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
case 'cucumber-js':
|
|
39
|
-
return runCucumberJs(projectRoot);
|
|
40
|
-
case 'pytest-bdd':
|
|
41
|
-
return runPytestBdd(projectRoot, mainPath);
|
|
42
|
-
default:
|
|
43
|
-
return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
|
|
91
|
+
const strategy = strategyFor(runner);
|
|
92
|
+
if (!strategy) {
|
|
93
|
+
return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
|
|
44
94
|
}
|
|
95
|
+
return strategy.run(projectRoot, mainPath);
|
|
96
|
+
}
|
|
97
|
+
// How this runner loads step files, for whoever has to write one. Null for a
|
|
98
|
+
// runner this connector does not run, which is the same answer `runBddSuite`
|
|
99
|
+
// gives it.
|
|
100
|
+
export function bddStepLoading(runner) {
|
|
101
|
+
return strategyFor(runner)?.loading ?? null;
|
|
102
|
+
}
|
|
103
|
+
// `Object.hasOwn` rather than a bare index: the runner name arrives over the
|
|
104
|
+
// wire, and `constructor` would otherwise come back as a truthy strategy with no
|
|
105
|
+
// `run` on it.
|
|
106
|
+
function strategyFor(runner) {
|
|
107
|
+
return Object.hasOwn(BDD_STRATEGIES, runner) ? BDD_STRATEGIES[runner] : null;
|
|
45
108
|
}
|
|
46
109
|
// Ruby: `cucumber` with the built-in message formatter. The features and step
|
|
47
110
|
// definitions both live under the behavioral root; --require points at the step
|
|
48
111
|
// definitions so only the Unitbob bundle loads.
|
|
49
112
|
async function runCucumberRuby(projectRoot) {
|
|
50
113
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
51
|
-
const steps = join(BEHAVIORAL_ROOT,
|
|
114
|
+
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
52
115
|
const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
|
|
53
116
|
if (!existsSync(sidecarGemfile)) {
|
|
54
117
|
throw missingRunner('Cucumber');
|
|
@@ -75,7 +138,7 @@ function missingRunner(name) {
|
|
|
75
138
|
// to a file.
|
|
76
139
|
async function runCucumberJs(projectRoot) {
|
|
77
140
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
78
|
-
const steps = join(BEHAVIORAL_ROOT,
|
|
141
|
+
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
|
|
79
142
|
const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
|
|
80
143
|
if (!executable(sidecarBin)) {
|
|
81
144
|
throw missingRunner('Cucumber JS');
|
|
@@ -103,7 +166,7 @@ async function runPytestBdd(projectRoot, mainPath) {
|
|
|
103
166
|
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
104
167
|
writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
|
|
105
168
|
const command = await pickPython(projectRoot);
|
|
106
|
-
const stepsDir = join(BEHAVIORAL_ROOT,
|
|
169
|
+
const stepsDir = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
107
170
|
const isVenvPytest = command.endsWith('/pytest');
|
|
108
171
|
const args = isVenvPytest
|
|
109
172
|
? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot]
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { executable, runProcess } from "../proc.js";
|
|
4
|
+
import { locateRunner } from "./toolchain.js";
|
|
4
5
|
import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
|
|
5
6
|
import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
|
|
6
7
|
import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
@@ -86,9 +87,14 @@ async function loadRubyHelper(projectRoot, helper, deps) {
|
|
|
86
87
|
// and the global `bundle` was standing right there the whole time.
|
|
87
88
|
const localBundle = join(projectRoot, 'bin', 'bundle');
|
|
88
89
|
const command = executable(localBundle) ? localBundle : 'bundle';
|
|
90
|
+
// When Unitbob installed rspec-rails for itself, the gems this helper needs
|
|
91
|
+
// are resolved by the sidecar Gemfile, not the project's. Asking bundler
|
|
92
|
+
// without that variable would load a different set of gems than the run does,
|
|
93
|
+
// which is exactly the way a check ends up predicting the wrong thing.
|
|
94
|
+
const located = locateRunner(projectRoot, 'rspec');
|
|
89
95
|
return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
|
|
90
96
|
cwd: projectRoot,
|
|
91
|
-
env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
97
|
+
env: { ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
92
98
|
}),
|
|
93
99
|
// A clean load says nothing on stdout and exits 0. Anything else is the
|
|
94
100
|
// suite failing to start.
|
|
@@ -110,10 +116,25 @@ async function pytestBootCheck(projectRoot, deps) {
|
|
|
110
116
|
// first: this check must not fail because a different step was skipped.
|
|
111
117
|
mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
|
|
112
118
|
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
// The same pytest the run will use, resolved once in `locateRunner` — the
|
|
120
|
+
// sidecar under `.unitbob/` when Unitbob installed one, else the machine's own
|
|
121
|
+
// interpreter. Asking a different interpreter than the run uses is how a check
|
|
122
|
+
// ends up answering about something nobody is going to execute.
|
|
123
|
+
//
|
|
124
|
+
// When it resolves nothing we still try the two interpreters by name rather
|
|
125
|
+
// than reporting `no_runner` from a lookup. The lookup is a prediction; the
|
|
126
|
+
// spawn is the fact, and a check that stops at its own prediction can be
|
|
127
|
+
// wrong in the one direction that costs the most — refusing a project that
|
|
128
|
+
// would have answered perfectly well.
|
|
129
|
+
const located = locateRunner(projectRoot, 'pytest');
|
|
130
|
+
const candidates = located
|
|
131
|
+
? [located]
|
|
132
|
+
: [
|
|
133
|
+
{ command: 'python3', args: ['-m', 'pytest'], env: undefined },
|
|
134
|
+
{ command: 'python', args: ['-m', 'pytest'], env: undefined },
|
|
135
|
+
];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--collect-only', '-q'], { cwd: projectRoot, env: candidate.env });
|
|
117
138
|
if (result === null)
|
|
118
139
|
continue; // this interpreter is not on the machine
|
|
119
140
|
return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
|
|
@@ -148,7 +169,10 @@ function pytestVerdict(code) {
|
|
|
148
169
|
// turn away the majority. A file that is genuinely unparseable is caught here
|
|
149
170
|
// anyway, since `vitest list` has to parse it.
|
|
150
171
|
async function vitestBootCheck(projectRoot, deps) {
|
|
151
|
-
|
|
172
|
+
// A sidecar vitest counts as installed: it is ours, it is on disk, and it is
|
|
173
|
+
// the one the run will spawn. What stays out is `npx`, for the reason below.
|
|
174
|
+
const local = locateRunner(projectRoot, 'vitest')?.command
|
|
175
|
+
?? join(projectRoot, 'node_modules', '.bin', 'vitest');
|
|
152
176
|
// Only a vitest already installed in the project is used. Reaching for `npx`
|
|
153
177
|
// would install a package to answer a question, and installing into the
|
|
154
178
|
// user's project is not this check's business.
|
|
@@ -275,15 +299,17 @@ function hasProjectFrame(output, projectRoot, runner) {
|
|
|
275
299
|
// asked of that file. Judging the whole output at once let `.venv/lib/...`
|
|
276
300
|
// answer yes on the strength of its `lib/`, which is how a `TypeError` deep
|
|
277
301
|
// inside a dependency came back as a defect in the user's code.
|
|
278
|
-
const ownDirs = runner
|
|
279
|
-
const conventional =
|
|
302
|
+
const ownDirs = CONVENTIONAL_SOURCE_DIRS[runner] ?? [];
|
|
303
|
+
const conventional = ownDirs.length
|
|
304
|
+
? new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`)
|
|
305
|
+
: null;
|
|
280
306
|
for (const line of output.split('\n')) {
|
|
281
307
|
// Wherever a dependency is installed, it is not this project's code — and
|
|
282
308
|
// that has to be decided before anything below gets a chance to say yes.
|
|
283
309
|
if (INSTALLED_DEPENDENCY.test(line))
|
|
284
310
|
continue;
|
|
285
311
|
// The conventional homes of business code, relative or absolute.
|
|
286
|
-
if (conventional
|
|
312
|
+
if (conventional?.test(line))
|
|
287
313
|
return true;
|
|
288
314
|
if (line.includes(projectRoot))
|
|
289
315
|
return true;
|
|
@@ -303,6 +329,18 @@ function hasProjectFrame(output, projectRoot, runner) {
|
|
|
303
329
|
}
|
|
304
330
|
return false;
|
|
305
331
|
}
|
|
332
|
+
// Where each stack conventionally keeps its business code. Only a stack that
|
|
333
|
+
// really has such a convention gets an entry: `app/` and `lib/` are Rails, and
|
|
334
|
+
// they used to be the fallback for everything that was not vitest, which meant a
|
|
335
|
+
// Python project got Rails's layout applied to its stack traces. Python names no
|
|
336
|
+
// fixed layout at all, so it is deliberately absent — the repository-file test
|
|
337
|
+
// below is the answer there, and it is the more reliable one anyway.
|
|
338
|
+
const CONVENTIONAL_SOURCE_DIRS = {
|
|
339
|
+
rspec: ['app', 'lib'],
|
|
340
|
+
cucumber: ['app', 'lib'],
|
|
341
|
+
vitest: ['src'],
|
|
342
|
+
'cucumber-js': ['src'],
|
|
343
|
+
};
|
|
306
344
|
// Where a dependency lives once installed — never the project's own code, in
|
|
307
345
|
// any of the three languages. The last two are the languages' own installed
|
|
308
346
|
// libraries: `…/lib/ruby/3.3.0/psych.rb` is a frame the `lib/` rule below would
|