unitbob 0.5.0 → 0.6.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 +37 -0
- package/dist/cli.js +9 -1
- package/dist/config.js +37 -1
- package/dist/files/behavioral.js +210 -10
- package/dist/files/guardrails.js +1 -1
- package/dist/files/suiteBuild.js +1 -1
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/proc.js +122 -2
- package/dist/runner/bdd.js +126 -48
- package/dist/runner/bootcheck.js +32 -20
- package/dist/runner/docker.js +139 -0
- package/dist/runner/place.js +181 -0
- package/dist/runner/placeAdvice.js +52 -0
- package/dist/runner/placeEnvironment.js +111 -0
- package/dist/runner/precheck.js +35 -0
- package/dist/runner/provision.js +63 -29
- package/dist/runner/pytest.js +9 -10
- package/dist/runner/pytestBddPlugin.js +11 -3
- package/dist/runner/rspec.js +14 -12
- package/dist/runner/toolchain.js +49 -11
- package/dist/runner/types.js +45 -1
- package/dist/runner/vitest.js +9 -10
- package/dist/runner/worldProbe.js +26 -14
- package/dist/surfaces/routeInventory.js +14 -12
- package/dist/verbs/codexInstall.js +51 -16
- package/dist/verbs/mapPrepare.js +16 -5
- package/dist/verbs/putSuiteBuild.js +9 -2
- package/dist/verbs/run.js +10 -2
- package/dist/verbs/runLocal.js +17 -2
- package/dist/verbs/suitePrepare.js +101 -9
- package/dist/verbs/suiteReviewPrepare.js +14 -0
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
package/dist/runner/bdd.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { executable
|
|
4
|
-
import {
|
|
3
|
+
import { executable } from "../proc.js";
|
|
4
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
|
+
import { commandFileOnHost } from "./toolchain.js";
|
|
6
|
+
import { clearReport, readFreshReport } from "./types.js";
|
|
5
7
|
import { PYTEST_BDD_PLUGIN } from "./pytestBddPlugin.js";
|
|
6
8
|
export const BDD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
9
|
// The behavioral suite lives under one root; the report is written inside it so
|
|
8
10
|
// the app under test cannot pollute it and it travels with the suite.
|
|
11
|
+
//
|
|
12
|
+
// Spelled here rather than imported from `files/behavioral.ts`, which spells it
|
|
13
|
+
// too: that module imports this one, and an import back would be a cycle whose
|
|
14
|
+
// only symptom is a use-before-initialization at load time.
|
|
9
15
|
const BEHAVIORAL_ROOT = '.unitbob/behavioral';
|
|
16
|
+
// The sidecar Gemfile bundler is pointed at, in one place — the world probe
|
|
17
|
+
// needs the same value and used to carry its own copy. A plain string rather
|
|
18
|
+
// than `join`: this path is read where the run happens, whose separator is not
|
|
19
|
+
// necessarily this machine's.
|
|
20
|
+
export const BEHAVIORAL_GEMFILE = `${BEHAVIORAL_ROOT}/Gemfile`;
|
|
10
21
|
const CUCUMBER_REPORT_NAME = 'cucumber_messages.ndjson';
|
|
11
22
|
const PYTEST_BDD_REPORT_NAME = 'pytest_bdd_report.json';
|
|
12
23
|
const PYTEST_BDD_PLUGIN_NAME = 'unitbob_pytest_bdd_plugin.py';
|
|
@@ -22,51 +33,112 @@ export const BDD_RUN_ARTIFACTS = [
|
|
|
22
33
|
PYTEST_BDD_PLUGIN_NAME,
|
|
23
34
|
PYTEST_INI_NAME,
|
|
24
35
|
];
|
|
36
|
+
// One name for the directory every strategy points its loader at, so the
|
|
37
|
+
// descriptors below and the commands below them cannot come to mean different
|
|
38
|
+
// directories.
|
|
39
|
+
const STEP_DEFINITIONS = 'step_definitions';
|
|
25
40
|
const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
|
|
26
41
|
const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
|
|
27
42
|
const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
|
|
28
43
|
const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
|
|
29
44
|
const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
45
|
+
// Load order is a fact about both Cucumbers and about neither pytest — pytest
|
|
46
|
+
// picks `conftest.py` up itself, so there is no trap to work around there.
|
|
47
|
+
const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file is not special to the runner — `account_access` ' +
|
|
48
|
+
'loads before `shared`. Open each capability file with an explicit require of the shared one rather ' +
|
|
49
|
+
'than trusting the alphabet.';
|
|
30
50
|
// The connector-owned BDD strategy table (spec 32): the `runner` enum names one
|
|
31
51
|
// of these; the connector never executes a host-provided command string. Each
|
|
32
52
|
// strategy runs the whole behavioral bundle and returns the raw machine-readable
|
|
33
53
|
// report verbatim — the connector does no marker join and no aggregation.
|
|
54
|
+
//
|
|
55
|
+
// A strategy is its command *and* its loading rule. They are one entry so that a
|
|
56
|
+
// fourth runner cannot be added with only half of itself stated.
|
|
57
|
+
const BDD_STRATEGIES = {
|
|
58
|
+
cucumber: {
|
|
59
|
+
run: (projectRoot) => runCucumberRuby(projectRoot),
|
|
60
|
+
loading: {
|
|
61
|
+
step_files: '*.rb',
|
|
62
|
+
requirements: [
|
|
63
|
+
'The connector points `--require` at `step_definitions/`, so every `.rb` file there is loaded. ' +
|
|
64
|
+
'That explicit `--require` also switches off Cucumber\'s automatic loading of `features/support/`: ' +
|
|
65
|
+
'a World or helper parked there is never evaluated, and every step then fails on a bare object.',
|
|
66
|
+
CUCUMBER_LOAD_ORDER,
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
'cucumber-js': {
|
|
71
|
+
run: (projectRoot) => runCucumberJs(projectRoot),
|
|
72
|
+
loading: {
|
|
73
|
+
step_files: '*.js',
|
|
74
|
+
requirements: [
|
|
75
|
+
'Keep `step_definitions/` to CommonJS JavaScript and nothing else. The connector passes the whole ' +
|
|
76
|
+
'directory to `--require`, and cucumber-js `require()`s every file it matches whatever the ' +
|
|
77
|
+
'extension — a stray `.ts`, `.json` or `.md` left there is executed as JavaScript and aborts the ' +
|
|
78
|
+
'entire run with a parse error, before a single scenario.',
|
|
79
|
+
'The connector registers no TypeScript loader, so a `.ts` file cannot compile itself. If you want ' +
|
|
80
|
+
'one, register the compiler from the file that sorts first — and remember the file registering it ' +
|
|
81
|
+
'is itself loaded as plain JavaScript.',
|
|
82
|
+
CUCUMBER_LOAD_ORDER,
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
'pytest-bdd': {
|
|
87
|
+
run: (projectRoot, mainPath) => runPytestBdd(projectRoot, mainPath),
|
|
88
|
+
loading: {
|
|
89
|
+
step_files: 'test_*.py',
|
|
90
|
+
requirements: [
|
|
91
|
+
'pytest collects `step_definitions/` under its own default, which is `test_*.py` and `*_test.py`. ' +
|
|
92
|
+
'The connector writes no `python_files` setting and will not: a file named outside those two — ' +
|
|
93
|
+
'`<capability>_steps.py`, say — is simply not collected. No error, no scenarios, a green run ' +
|
|
94
|
+
'over nothing.',
|
|
95
|
+
'`conftest.py` is picked up by pytest itself whatever else sits beside it, so shared fixtures ' +
|
|
96
|
+
'belong there and there is no load-order trap to work around.',
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
34
101
|
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.`));
|
|
102
|
+
const strategy = strategyFor(runner);
|
|
103
|
+
if (!strategy) {
|
|
104
|
+
return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
|
|
44
105
|
}
|
|
106
|
+
return strategy.run(projectRoot, mainPath);
|
|
107
|
+
}
|
|
108
|
+
// How this runner loads step files, for whoever has to write one. Null for a
|
|
109
|
+
// runner this connector does not run, which is the same answer `runBddSuite`
|
|
110
|
+
// gives it.
|
|
111
|
+
export function bddStepLoading(runner) {
|
|
112
|
+
return strategyFor(runner)?.loading ?? null;
|
|
113
|
+
}
|
|
114
|
+
// `Object.hasOwn` rather than a bare index: the runner name arrives over the
|
|
115
|
+
// wire, and `constructor` would otherwise come back as a truthy strategy with no
|
|
116
|
+
// `run` on it.
|
|
117
|
+
function strategyFor(runner) {
|
|
118
|
+
return Object.hasOwn(BDD_STRATEGIES, runner) ? BDD_STRATEGIES[runner] : null;
|
|
45
119
|
}
|
|
46
120
|
// Ruby: `cucumber` with the built-in message formatter. The features and step
|
|
47
121
|
// definitions both live under the behavioral root; --require points at the step
|
|
48
122
|
// definitions so only the Unitbob bundle loads.
|
|
49
123
|
async function runCucumberRuby(projectRoot) {
|
|
50
124
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
51
|
-
const steps = join(BEHAVIORAL_ROOT,
|
|
52
|
-
const sidecarGemfile = join(projectRoot,
|
|
125
|
+
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
126
|
+
const sidecarGemfile = join(projectRoot, BEHAVIORAL_GEMFILE);
|
|
53
127
|
if (!existsSync(sidecarGemfile)) {
|
|
54
128
|
throw missingRunner('Cucumber');
|
|
55
129
|
}
|
|
56
130
|
const command = 'bundle';
|
|
57
131
|
const args = ['exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
RAILS_ENV: 'test',
|
|
61
|
-
UNITBOB_REPO_ROOT: projectRoot,
|
|
62
|
-
};
|
|
63
|
-
env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
|
|
64
|
-
const result = await runProcess(command, args, {
|
|
65
|
-
cwd: projectRoot,
|
|
132
|
+
const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
|
|
133
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
66
134
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
67
|
-
env
|
|
135
|
+
env: {
|
|
136
|
+
RAILS_ENV: 'test',
|
|
137
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
138
|
+
BUNDLE_GEMFILE: BEHAVIORAL_GEMFILE,
|
|
139
|
+
},
|
|
68
140
|
});
|
|
69
|
-
return finalize(
|
|
141
|
+
return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
|
|
70
142
|
}
|
|
71
143
|
function missingRunner(name) {
|
|
72
144
|
return new Error(`Behavioral runner missing (${name}). Run suite-prepare to provision it under ${BEHAVIORAL_ROOT}/, then run the checks again.`);
|
|
@@ -75,12 +147,11 @@ function missingRunner(name) {
|
|
|
75
147
|
// to a file.
|
|
76
148
|
async function runCucumberJs(projectRoot) {
|
|
77
149
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
78
|
-
const steps = join(BEHAVIORAL_ROOT,
|
|
79
|
-
const
|
|
80
|
-
if (!executable(
|
|
150
|
+
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
|
|
151
|
+
const command = `${BEHAVIORAL_ROOT}/node_modules/.bin/cucumber-js`;
|
|
152
|
+
if (!executable(commandFileOnHost(projectRoot, command))) {
|
|
81
153
|
throw missingRunner('Cucumber JS');
|
|
82
154
|
}
|
|
83
|
-
const command = sidecarBin;
|
|
84
155
|
const args = [
|
|
85
156
|
features,
|
|
86
157
|
'--require',
|
|
@@ -88,12 +159,12 @@ async function runCucumberJs(projectRoot) {
|
|
|
88
159
|
'--format',
|
|
89
160
|
`message:${CUCUMBER_REPORT}`,
|
|
90
161
|
];
|
|
91
|
-
const
|
|
92
|
-
|
|
162
|
+
const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
|
|
163
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
93
164
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
94
|
-
env: {
|
|
165
|
+
env: { NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
|
|
95
166
|
});
|
|
96
|
-
return finalize(
|
|
167
|
+
return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
|
|
97
168
|
}
|
|
98
169
|
// Python: pytest driving pytest-bdd, with the connector's reporter plugin. The
|
|
99
170
|
// plugin writes the JSON report; `-c` isolates the run from the project's own
|
|
@@ -103,40 +174,47 @@ async function runPytestBdd(projectRoot, mainPath) {
|
|
|
103
174
|
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
104
175
|
writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
|
|
105
176
|
const command = await pickPython(projectRoot);
|
|
106
|
-
const stepsDir = join(BEHAVIORAL_ROOT,
|
|
177
|
+
const stepsDir = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
107
178
|
const isVenvPytest = command.endsWith('/pytest');
|
|
179
|
+
// `--rootdir .`, not the absolute root: the working directory is the project
|
|
180
|
+
// root in every place, and an absolute host path would name a directory that
|
|
181
|
+
// does not exist wherever the run actually happens.
|
|
108
182
|
const args = isVenvPytest
|
|
109
|
-
? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir',
|
|
110
|
-
: ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir',
|
|
111
|
-
const
|
|
112
|
-
|
|
183
|
+
? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', '.']
|
|
184
|
+
: ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', '.'];
|
|
185
|
+
const survivor = clearReport(join(projectRoot, PYTEST_BDD_REPORT));
|
|
186
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
113
187
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
114
188
|
env: {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
189
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
190
|
+
// Relative, and the plugin makes it absolute the moment it is imported —
|
|
191
|
+
// before any fixture has had a chance to change directory. Sent as a host
|
|
192
|
+
// path it would name a directory the run cannot see.
|
|
193
|
+
UNITBOB_PYTEST_BDD_REPORT: PYTEST_BDD_REPORT,
|
|
194
|
+
// The connector's own plugin directory, and only it. The host's own
|
|
195
|
+
// PYTHONPATH used to be appended here; it names host directories, which
|
|
196
|
+
// mean nothing where the run happens, and passing on the connector's
|
|
197
|
+
// environment is exactly what a place must not do.
|
|
198
|
+
PYTHONPATH: BEHAVIORAL_ROOT,
|
|
119
199
|
},
|
|
120
200
|
});
|
|
121
|
-
return finalize(
|
|
201
|
+
return finalize(run, projectRoot, PYTEST_BDD_REPORT, survivor);
|
|
122
202
|
// mainPath is accepted for symmetry with the structural runners; pytest-bdd
|
|
123
203
|
// discovers scenarios from the step-definition modules, not the .feature path.
|
|
124
204
|
}
|
|
125
205
|
function pluginModule() {
|
|
126
206
|
return 'unitbob_pytest_bdd_plugin';
|
|
127
207
|
}
|
|
128
|
-
function finalize(
|
|
208
|
+
function finalize(run, projectRoot, reportRel, survivor) {
|
|
129
209
|
return {
|
|
130
|
-
...
|
|
131
|
-
command,
|
|
132
|
-
args,
|
|
210
|
+
...run,
|
|
133
211
|
resultPath: reportRel,
|
|
134
|
-
report:
|
|
212
|
+
report: readFreshReport(join(projectRoot, reportRel), survivor),
|
|
135
213
|
};
|
|
136
214
|
}
|
|
137
215
|
async function pickPython(projectRoot) {
|
|
138
|
-
const sidecarVenvPytest =
|
|
139
|
-
if (executable(sidecarVenvPytest)) {
|
|
216
|
+
const sidecarVenvPytest = `${BEHAVIORAL_ROOT}/.venv/bin/pytest`;
|
|
217
|
+
if (executable(commandFileOnHost(projectRoot, sidecarVenvPytest))) {
|
|
140
218
|
return sidecarVenvPytest;
|
|
141
219
|
}
|
|
142
220
|
throw missingRunner('pytest-bdd');
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { executable
|
|
4
|
-
import {
|
|
3
|
+
import { executable } from "../proc.js";
|
|
4
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
|
+
import { commandFileOnHost, locateRunner } from "./toolchain.js";
|
|
5
6
|
import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
|
|
6
7
|
import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
|
|
7
8
|
import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
8
9
|
const defaultDeps = {
|
|
9
|
-
runCmd: (command, args, options) =>
|
|
10
|
-
cwd: options.cwd,
|
|
11
|
-
timeoutMs: PROVISION_TIMEOUT_MS,
|
|
12
|
-
env: { ...process.env, ...options.env },
|
|
13
|
-
}),
|
|
10
|
+
runCmd: (command, args, options) => runInProject(options.cwd, command, args, { timeoutMs: PROVISION_TIMEOUT_MS, env: options.env }),
|
|
14
11
|
};
|
|
15
12
|
// How much of a runner's output rides along in `detail`. Enough to see the
|
|
16
13
|
// stack that mattered, short enough that a stop message stays readable.
|
|
@@ -49,8 +46,8 @@ export async function bootCheck(projectRoot, runner, deps = defaultDeps) {
|
|
|
49
46
|
// `spec/support` and the project's own configuration all come along for free,
|
|
50
47
|
// without this module knowing anything about them.
|
|
51
48
|
async function rubyBootCheck(projectRoot, deps) {
|
|
52
|
-
const helper =
|
|
53
|
-
if (!existsSync(helper))
|
|
49
|
+
const helper = `${GUARDRAILS_DIR}/${HELPER_FILE}`;
|
|
50
|
+
if (!existsSync(join(projectRoot, helper)))
|
|
54
51
|
return { status: 'not_checked', reason: 'nothing_to_load' };
|
|
55
52
|
const first = await loadRubyHelper(projectRoot, helper, deps);
|
|
56
53
|
if (first.status !== 'broken')
|
|
@@ -85,16 +82,25 @@ async function loadRubyHelper(projectRoot, helper, deps) {
|
|
|
85
82
|
// someone whose bundler is installed and working. That is the mistake
|
|
86
83
|
// `runner_too_old` and `runner_could_not_answer` were added to stop making,
|
|
87
84
|
// and the global `bundle` was standing right there the whole time.
|
|
88
|
-
const
|
|
89
|
-
const command = executable(localBundle) ? localBundle : 'bundle';
|
|
85
|
+
const command = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
|
|
90
86
|
// When Unitbob installed rspec-rails for itself, the gems this helper needs
|
|
91
87
|
// are resolved by the sidecar Gemfile, not the project's. Asking bundler
|
|
92
88
|
// without that variable would load a different set of gems than the run does,
|
|
93
89
|
// which is exactly the way a check ends up predicting the wrong thing.
|
|
94
90
|
const located = locateRunner(projectRoot, 'rspec');
|
|
95
|
-
|
|
91
|
+
// `require "./…"`, and the leading dot is not cosmetic: Ruby resolves a
|
|
92
|
+
// relative require against `$LOAD_PATH`, which does not hold the working
|
|
93
|
+
// directory, and only a path beginning with `.` is resolved against the
|
|
94
|
+
// working directory instead. The helper finds its own neighbours through
|
|
95
|
+
// `__dir__`, which is absolute however the file was reached, so nothing else
|
|
96
|
+
// about it changes.
|
|
97
|
+
return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(`./${helper}`)}`], {
|
|
96
98
|
cwd: projectRoot,
|
|
97
|
-
env: {
|
|
99
|
+
env: {
|
|
100
|
+
...located?.env,
|
|
101
|
+
RAILS_ENV: 'test',
|
|
102
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
103
|
+
},
|
|
98
104
|
}),
|
|
99
105
|
// A clean load says nothing on stdout and exits 0. Anything else is the
|
|
100
106
|
// suite failing to start.
|
|
@@ -171,8 +177,7 @@ function pytestVerdict(code) {
|
|
|
171
177
|
async function vitestBootCheck(projectRoot, deps) {
|
|
172
178
|
// A sidecar vitest counts as installed: it is ours, it is on disk, and it is
|
|
173
179
|
// 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');
|
|
180
|
+
const local = locateRunner(projectRoot, 'vitest')?.command ?? 'node_modules/.bin/vitest';
|
|
176
181
|
// Only a vitest already installed in the project is used. Reaching for `npx`
|
|
177
182
|
// would install a package to answer a question, and installing into the
|
|
178
183
|
// user's project is not this check's business.
|
|
@@ -182,7 +187,7 @@ async function vitestBootCheck(projectRoot, deps) {
|
|
|
182
187
|
// "cannot be asked", and the answer came back `runner_too_old` — a positive
|
|
183
188
|
// falsehood about a version nobody looked at. `no_runner` is the honest one
|
|
184
189
|
// here: there is no vitest this check can invoke.
|
|
185
|
-
if (!executable(local))
|
|
190
|
+
if (!executable(commandFileOnHost(projectRoot, local)))
|
|
186
191
|
return { status: 'not_checked', reason: 'no_runner' };
|
|
187
192
|
// `list` is a subcommand only from Vitest 2.1. Older versions read it as a
|
|
188
193
|
// *filename filter* and go on to run whatever it matches, which was measured
|
|
@@ -243,6 +248,14 @@ function classify(projectRoot, runner, result, verdict) {
|
|
|
243
248
|
// nothing about the code, so it must not read as a defect.
|
|
244
249
|
if (result.code === null)
|
|
245
250
|
return { status: 'not_checked', reason: 'timed_out' };
|
|
251
|
+
// Before the exit code is read at all, and that order is the whole point. The
|
|
252
|
+
// codes docker returns for its own failures overlap with real runners' codes,
|
|
253
|
+
// and one step further down `causeOf` picks a cause by matching the output
|
|
254
|
+
// against a path pattern — so a container that stopped mid-run could come back
|
|
255
|
+
// as "found a defect that stops your test suite from starting". An accusation
|
|
256
|
+
// about somebody's code, for a failure of the daemon.
|
|
257
|
+
if (result.placeFailure)
|
|
258
|
+
return { status: 'not_checked', reason: 'place_failed', detail: result.placeFailure };
|
|
246
259
|
const outcome = verdict(result);
|
|
247
260
|
if (outcome === 'ok')
|
|
248
261
|
return { status: 'ok' };
|
|
@@ -374,13 +387,12 @@ export function firstErrorLine(output) {
|
|
|
374
387
|
async function prepareTestDatabase(projectRoot, deps) {
|
|
375
388
|
if (!testDatabaseIsSeparate(projectRoot))
|
|
376
389
|
return false;
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
const command = useBinstub ? rails : 'bundle';
|
|
390
|
+
const useBinstub = executable(join(projectRoot, 'bin', 'rails'));
|
|
391
|
+
const command = useBinstub ? 'bin/rails' : 'bundle';
|
|
380
392
|
const args = useBinstub ? ['db:test:prepare'] : ['exec', 'rails', 'db:test:prepare'];
|
|
381
393
|
const result = await attempt(deps, command, args, {
|
|
382
394
|
cwd: projectRoot,
|
|
383
|
-
env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
395
|
+
env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
|
|
384
396
|
});
|
|
385
397
|
return result !== null && result.code === 0;
|
|
386
398
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Everything this connector knows about Docker, in one file (spec 36).
|
|
2
|
+
//
|
|
3
|
+
// It is deliberately not "a command prefix the user configures". A prefix would
|
|
4
|
+
// be three lines and would make every message below impossible: with only a
|
|
5
|
+
// string to run, nothing can say "that container is not running", "the project
|
|
6
|
+
// is not mounted into it", or "here is the container that already has your
|
|
7
|
+
// project". Those sentences are the point of the spec, so the connector knows
|
|
8
|
+
// what a container is.
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { posix, relative, sep } from 'node:path';
|
|
12
|
+
const inspected = new Map();
|
|
13
|
+
// Where this container sees the project root, or why it cannot.
|
|
14
|
+
//
|
|
15
|
+
// The path inside is never configured. It is derived from the container's own
|
|
16
|
+
// mounts: the deepest mount whose source is the start of the path to the project
|
|
17
|
+
// root, with the remainder appended to its destination. Exact equality (`.:/app`)
|
|
18
|
+
// is the common case but not the only one — a monorepo mounts the parent
|
|
19
|
+
// (`..:/workspace`) and the project sits inside it, and demanding equality would
|
|
20
|
+
// report "your code is copied into the image" about a project that is plainly
|
|
21
|
+
// mounted.
|
|
22
|
+
//
|
|
23
|
+
// This doubles as the bind-mount check the whole invariant rests on: no mount
|
|
24
|
+
// covering the root means the code was copied into the image, which means files
|
|
25
|
+
// written here are invisible there and reports written there are invisible here.
|
|
26
|
+
export function containerProjectRoot(container, projectRoot) {
|
|
27
|
+
const found = inspected.get(container) ?? inspect(container);
|
|
28
|
+
inspected.set(container, found);
|
|
29
|
+
if ('failure' in found)
|
|
30
|
+
return found.failure;
|
|
31
|
+
if (found.container.State?.Running === false)
|
|
32
|
+
return { status: 'not_running' };
|
|
33
|
+
const mounted = mountedRoot(found.container.Mounts ?? [], projectRoot);
|
|
34
|
+
return mounted === null ? { status: 'not_mounted' } : { status: 'ok', projectRoot: mounted };
|
|
35
|
+
}
|
|
36
|
+
function inspect(container) {
|
|
37
|
+
const result = spawnSync('docker', ['container', 'inspect', container, '--format', '{{json .}}'], {
|
|
38
|
+
timeout: 30_000,
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
});
|
|
41
|
+
// `docker` is not on this machine at all — a different sentence from any
|
|
42
|
+
// answer about the container, and the only one whose fix is "install Docker".
|
|
43
|
+
if (result.error && result.error.code === 'ENOENT')
|
|
44
|
+
return { failure: { status: 'no_docker' } };
|
|
45
|
+
if (result.error)
|
|
46
|
+
return { failure: { status: 'unreadable', detail: result.error.message } };
|
|
47
|
+
const stderr = (result.stderr ?? '').trim();
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
if (/no such (container|object)/i.test(stderr))
|
|
50
|
+
return { failure: { status: 'no_container' } };
|
|
51
|
+
if (/cannot connect to the docker daemon/i.test(stderr))
|
|
52
|
+
return { failure: { status: 'no_docker' } };
|
|
53
|
+
return { failure: { status: 'unreadable', detail: stderr || `docker inspect exited ${result.status}` } };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return { container: JSON.parse(result.stdout) };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { failure: { status: 'unreadable', detail: `could not read what docker said (${err.message})` } };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// The deepest mount that holds this project, translated to the path inside.
|
|
63
|
+
//
|
|
64
|
+
// Both sides are resolved through their symlinks before they are compared. On
|
|
65
|
+
// macOS `/tmp` is a link to `/private/tmp`, so a project checked out under one
|
|
66
|
+
// and mounted as the other is the classic way to be told "there is no mount"
|
|
67
|
+
// where there plainly is one.
|
|
68
|
+
function mountedRoot(mounts, projectRoot) {
|
|
69
|
+
const root = resolved(projectRoot);
|
|
70
|
+
let best = null;
|
|
71
|
+
for (const mount of mounts) {
|
|
72
|
+
if (!mount.Source || !mount.Destination)
|
|
73
|
+
continue;
|
|
74
|
+
const inside = relative(resolved(mount.Source), root);
|
|
75
|
+
// Outside this mount, or on the far side of a `..`, or an absolute path
|
|
76
|
+
// (which `relative` returns when the two share no root at all).
|
|
77
|
+
if (inside.startsWith('..') || inside.startsWith('/'))
|
|
78
|
+
continue;
|
|
79
|
+
const depth = resolved(mount.Source).split(sep).length;
|
|
80
|
+
if (best && best.depth >= depth)
|
|
81
|
+
continue;
|
|
82
|
+
// The container's own filesystem, so its own separator: a Windows host
|
|
83
|
+
// still mounts into a Linux path.
|
|
84
|
+
best = { depth, path: inside === '' ? mount.Destination : posix.join(mount.Destination, inside.split(sep).join('/')) };
|
|
85
|
+
}
|
|
86
|
+
return best?.path ?? null;
|
|
87
|
+
}
|
|
88
|
+
function resolved(path) {
|
|
89
|
+
try {
|
|
90
|
+
return existsSync(path) ? realpathSync(path) : path;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return path;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// The command as it is actually executed. The wrapper is not hidden from
|
|
97
|
+
// anybody: the "ran:" line a person reads and the command string that travels to
|
|
98
|
+
// the brain on a failure both show this, because a line that quietly drops the
|
|
99
|
+
// wrapper is a line that fails differently when somebody pastes it.
|
|
100
|
+
export function dockerExec(container, workingDirectory, env, command, args) {
|
|
101
|
+
const variables = Object.entries(env).flatMap(([key, value]) => ['-e', `${key}=${value}`]);
|
|
102
|
+
return { command: 'docker', args: ['exec', '-w', workingDirectory, ...variables, container, command, ...args] };
|
|
103
|
+
}
|
|
104
|
+
// Docker's own failures, told apart from the project's.
|
|
105
|
+
//
|
|
106
|
+
// `docker exec` returns 125, 126 and 127 for reasons entirely its own — the
|
|
107
|
+
// container stopped between the check and the spawn, the executable is not in
|
|
108
|
+
// the image — and those codes collide with real runners' codes. Left
|
|
109
|
+
// unmarked, a boot check reads the exit code as "broken", picks a cause by
|
|
110
|
+
// matching the output against a path pattern, and can announce that it "found a
|
|
111
|
+
// defect that stops your test suite from starting". That would be an accusation
|
|
112
|
+
// about somebody's code for a failure of the daemon.
|
|
113
|
+
//
|
|
114
|
+
// So the code alone is never enough: the client has to have said something only
|
|
115
|
+
// it says.
|
|
116
|
+
const DOCKER_SPEAKING = /Error response from daemon|is not running|No such container|OCI runtime exec failed|executable file not found/i;
|
|
117
|
+
export function dockerOwnFailure(result) {
|
|
118
|
+
if (result.code !== 125 && result.code !== 126 && result.code !== 127)
|
|
119
|
+
return null;
|
|
120
|
+
return DOCKER_SPEAKING.test(result.stderr) ? result.stderr.trim() : null;
|
|
121
|
+
}
|
|
122
|
+
// Every running container that already holds this project, for the message
|
|
123
|
+
// somebody gets when their toolchain is nowhere to be found on this machine.
|
|
124
|
+
//
|
|
125
|
+
// Only ever called once a command has decided to fail. On the successful path
|
|
126
|
+
// `docker` is not run at all, so a project with no Docker in sight pays nothing
|
|
127
|
+
// and is asked nothing.
|
|
128
|
+
export function containersHolding(projectRoot) {
|
|
129
|
+
const listed = spawnSync('docker', ['ps', '--format', '{{.Names}}'], { timeout: 30_000, encoding: 'utf8' });
|
|
130
|
+
if (listed.error || listed.status !== 0)
|
|
131
|
+
return [];
|
|
132
|
+
const found = [];
|
|
133
|
+
for (const name of listed.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
134
|
+
const lookup = containerProjectRoot(name, projectRoot);
|
|
135
|
+
if (lookup.status === 'ok')
|
|
136
|
+
found.push({ name, projectRoot: lookup.projectRoot });
|
|
137
|
+
}
|
|
138
|
+
return found;
|
|
139
|
+
}
|