unitbob 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +51 -36
- 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 +1 -1
- 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 +62 -11
- package/dist/verbs/suiteReviewPrepare.js +14 -0
- package/dist/verbs/validateWorkerCheckpoints.js +51 -1
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +7 -3
- package/plugin/codex/agents/suite-worker.toml +33 -8
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';
|
|
@@ -112,24 +123,22 @@ function strategyFor(runner) {
|
|
|
112
123
|
async function runCucumberRuby(projectRoot) {
|
|
113
124
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
114
125
|
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
115
|
-
const sidecarGemfile = join(projectRoot,
|
|
126
|
+
const sidecarGemfile = join(projectRoot, BEHAVIORAL_GEMFILE);
|
|
116
127
|
if (!existsSync(sidecarGemfile)) {
|
|
117
128
|
throw missingRunner('Cucumber');
|
|
118
129
|
}
|
|
119
130
|
const command = 'bundle';
|
|
120
131
|
const args = ['exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
RAILS_ENV: 'test',
|
|
124
|
-
UNITBOB_REPO_ROOT: projectRoot,
|
|
125
|
-
};
|
|
126
|
-
env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
|
|
127
|
-
const result = await runProcess(command, args, {
|
|
128
|
-
cwd: projectRoot,
|
|
132
|
+
const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
|
|
133
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
129
134
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
130
|
-
env
|
|
135
|
+
env: {
|
|
136
|
+
RAILS_ENV: 'test',
|
|
137
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
138
|
+
BUNDLE_GEMFILE: BEHAVIORAL_GEMFILE,
|
|
139
|
+
},
|
|
131
140
|
});
|
|
132
|
-
return finalize(
|
|
141
|
+
return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
|
|
133
142
|
}
|
|
134
143
|
function missingRunner(name) {
|
|
135
144
|
return new Error(`Behavioral runner missing (${name}). Run suite-prepare to provision it under ${BEHAVIORAL_ROOT}/, then run the checks again.`);
|
|
@@ -139,11 +148,10 @@ function missingRunner(name) {
|
|
|
139
148
|
async function runCucumberJs(projectRoot) {
|
|
140
149
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
141
150
|
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
|
|
142
|
-
const
|
|
143
|
-
if (!executable(
|
|
151
|
+
const command = `${BEHAVIORAL_ROOT}/node_modules/.bin/cucumber-js`;
|
|
152
|
+
if (!executable(commandFileOnHost(projectRoot, command))) {
|
|
144
153
|
throw missingRunner('Cucumber JS');
|
|
145
154
|
}
|
|
146
|
-
const command = sidecarBin;
|
|
147
155
|
const args = [
|
|
148
156
|
features,
|
|
149
157
|
'--require',
|
|
@@ -151,12 +159,12 @@ async function runCucumberJs(projectRoot) {
|
|
|
151
159
|
'--format',
|
|
152
160
|
`message:${CUCUMBER_REPORT}`,
|
|
153
161
|
];
|
|
154
|
-
const
|
|
155
|
-
|
|
162
|
+
const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
|
|
163
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
156
164
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
157
|
-
env: {
|
|
165
|
+
env: { NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
|
|
158
166
|
});
|
|
159
|
-
return finalize(
|
|
167
|
+
return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
|
|
160
168
|
}
|
|
161
169
|
// Python: pytest driving pytest-bdd, with the connector's reporter plugin. The
|
|
162
170
|
// plugin writes the JSON report; `-c` isolates the run from the project's own
|
|
@@ -168,38 +176,45 @@ async function runPytestBdd(projectRoot, mainPath) {
|
|
|
168
176
|
const command = await pickPython(projectRoot);
|
|
169
177
|
const stepsDir = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
170
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.
|
|
171
182
|
const args = isVenvPytest
|
|
172
|
-
? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir',
|
|
173
|
-
: ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir',
|
|
174
|
-
const
|
|
175
|
-
|
|
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, {
|
|
176
187
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
177
188
|
env: {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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,
|
|
182
199
|
},
|
|
183
200
|
});
|
|
184
|
-
return finalize(
|
|
201
|
+
return finalize(run, projectRoot, PYTEST_BDD_REPORT, survivor);
|
|
185
202
|
// mainPath is accepted for symmetry with the structural runners; pytest-bdd
|
|
186
203
|
// discovers scenarios from the step-definition modules, not the .feature path.
|
|
187
204
|
}
|
|
188
205
|
function pluginModule() {
|
|
189
206
|
return 'unitbob_pytest_bdd_plugin';
|
|
190
207
|
}
|
|
191
|
-
function finalize(
|
|
208
|
+
function finalize(run, projectRoot, reportRel, survivor) {
|
|
192
209
|
return {
|
|
193
|
-
...
|
|
194
|
-
command,
|
|
195
|
-
args,
|
|
210
|
+
...run,
|
|
196
211
|
resultPath: reportRel,
|
|
197
|
-
report:
|
|
212
|
+
report: readFreshReport(join(projectRoot, reportRel), survivor),
|
|
198
213
|
};
|
|
199
214
|
}
|
|
200
215
|
async function pickPython(projectRoot) {
|
|
201
|
-
const sidecarVenvPytest =
|
|
202
|
-
if (executable(sidecarVenvPytest)) {
|
|
216
|
+
const sidecarVenvPytest = `${BEHAVIORAL_ROOT}/.venv/bin/pytest`;
|
|
217
|
+
if (executable(commandFileOnHost(projectRoot, sidecarVenvPytest))) {
|
|
203
218
|
return sidecarVenvPytest;
|
|
204
219
|
}
|
|
205
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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Where this project's own processes start (spec 36).
|
|
2
|
+
//
|
|
3
|
+
// A mature team's test environment does not live on the machine the connector
|
|
4
|
+
// was called on: the code is on the host, and the interpreter, the packages and
|
|
5
|
+
// the database are inside a container. So "run it here" stops being a fact and
|
|
6
|
+
// becomes a question, and this module is the one place that answers it.
|
|
7
|
+
//
|
|
8
|
+
// The invariant the whole spec is built on:
|
|
9
|
+
//
|
|
10
|
+
// Files stay on the host. Processes run where the dependencies live.
|
|
11
|
+
//
|
|
12
|
+
// Everything the connector reads and writes — request packets, suite files, run
|
|
13
|
+
// reports — it reads and writes on the host with ordinary filesystem calls. Only
|
|
14
|
+
// the processes that need the project's own toolchain travel.
|
|
15
|
+
//
|
|
16
|
+
// Which processes those are is not decided here and is deliberately not decided
|
|
17
|
+
// by a mode, a flag, or a global. It is decided at each call site by which
|
|
18
|
+
// function it calls: `runInProject` for a command that needs the project's
|
|
19
|
+
// dependencies, `runProcess` for a tool the connector brought with it (graphify
|
|
20
|
+
// is installed on the vibecoder's machine and is nowhere to be found inside
|
|
21
|
+
// somebody else's image). The rule is visible in the call itself, so the next
|
|
22
|
+
// reader sees the boundary without reading this file.
|
|
23
|
+
//
|
|
24
|
+
// The place itself is a property of the *project*, not of the run: it is written
|
|
25
|
+
// in `.unitbob.json` at the project root. So this module works it out from the
|
|
26
|
+
// project root it is handed, and no caller sets it, passes it, or can forget to.
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { readLocalExecContainer } from "../config.js";
|
|
29
|
+
import { runProcess } from "../proc.js";
|
|
30
|
+
import { containerProjectRoot, dockerExec, dockerOwnFailure } from "./docker.js";
|
|
31
|
+
// The place this project's processes run in.
|
|
32
|
+
//
|
|
33
|
+
// Read from the project on every call rather than resolved once into a module
|
|
34
|
+
// variable. A setter would be cheap and would introduce exactly the failure this
|
|
35
|
+
// spec exists to remove: a new verb forgets to call it, the connector quietly
|
|
36
|
+
// runs on the host, and nobody finds out.
|
|
37
|
+
export function placeOf(projectRoot) {
|
|
38
|
+
const container = readLocalExecContainer(projectRoot);
|
|
39
|
+
return container ? { kind: 'docker', container } : { kind: 'local' };
|
|
40
|
+
}
|
|
41
|
+
// How this place is written down in `.unitbob/.place`, so a runner environment
|
|
42
|
+
// built by one place is never mistaken for one built by another.
|
|
43
|
+
const DOCKER_MARK = 'docker:';
|
|
44
|
+
export function placeId(place) {
|
|
45
|
+
return place.kind === 'docker' ? `${DOCKER_MARK}${place.container}` : 'local';
|
|
46
|
+
}
|
|
47
|
+
// The same mark in words, for the one message that has to name both the place an
|
|
48
|
+
// environment was built in and the place this run happens in. It lives beside
|
|
49
|
+
// `placeId` because a format written in one file and taken apart in another is a
|
|
50
|
+
// format that drifts.
|
|
51
|
+
export function describePlaceId(id) {
|
|
52
|
+
return id.startsWith(DOCKER_MARK) ? `the container \`${id.slice(DOCKER_MARK.length)}\`` : 'this machine';
|
|
53
|
+
}
|
|
54
|
+
// The project root as the place sees it.
|
|
55
|
+
//
|
|
56
|
+
// Exactly one value in the system has to know where the root is —
|
|
57
|
+
// `UNITBOB_REPO_ROOT`, which the generated Ruby joins `config/environment` onto.
|
|
58
|
+
// It is built from the place rather than rewritten from a host path, so on the
|
|
59
|
+
// local place it is byte for byte the path it has always been.
|
|
60
|
+
export function projectRootAsSeenByThePlace(projectRoot) {
|
|
61
|
+
const place = placeOf(projectRoot);
|
|
62
|
+
if (place.kind === 'local')
|
|
63
|
+
return projectRoot;
|
|
64
|
+
const lookup = containerProjectRoot(place.container, projectRoot);
|
|
65
|
+
if (lookup.status === 'ok')
|
|
66
|
+
return lookup.projectRoot;
|
|
67
|
+
// Every command that must not run blind has been through `placeProblem`
|
|
68
|
+
// first. The one that has not is `map-prepare`, which asks the router and is
|
|
69
|
+
// allowed to fail — it degrades to one line and the map is built from source
|
|
70
|
+
// as it always was. So this returns something rather than throwing out of what
|
|
71
|
+
// reads like a getter: the command it belongs to is not going to run anyway.
|
|
72
|
+
return projectRoot;
|
|
73
|
+
}
|
|
74
|
+
// Run one of the project's own commands, in the project's own root.
|
|
75
|
+
//
|
|
76
|
+
// The working directory is always the project root, and that is the discipline
|
|
77
|
+
// that keeps host paths out of commands rather than a rule that rewrites them:
|
|
78
|
+
// with the root as the working directory, every argument a command needs can be
|
|
79
|
+
// relative, and a relative argument means the same thing in every place.
|
|
80
|
+
export async function runInProject(projectRoot, command, args, options = {}) {
|
|
81
|
+
const place = placeOf(projectRoot);
|
|
82
|
+
if (place.kind === 'local') {
|
|
83
|
+
const result = await runProcess(command, args, {
|
|
84
|
+
cwd: projectRoot,
|
|
85
|
+
timeoutMs: options.timeoutMs,
|
|
86
|
+
env: { ...process.env, ...options.env },
|
|
87
|
+
});
|
|
88
|
+
return { ...result, command, args };
|
|
89
|
+
}
|
|
90
|
+
const shaped = shapeForContainer(place.container, projectRoot, command, args, options.env ?? {});
|
|
91
|
+
const result = await runProcess(shaped.command, shaped.args, {
|
|
92
|
+
cwd: projectRoot,
|
|
93
|
+
timeoutMs: options.timeoutMs,
|
|
94
|
+
// The environment of the `docker` client, not of the command: everything the
|
|
95
|
+
// command is meant to see was listed with `-e` above. This machine's own
|
|
96
|
+
// PATH and HOME never travel — a container has a PATH of its own, and
|
|
97
|
+
// overwriting it hides the very `bundle` the image was built with.
|
|
98
|
+
env: process.env,
|
|
99
|
+
});
|
|
100
|
+
const failure = dockerOwnFailure(result);
|
|
101
|
+
return { ...result, ...(failure ? { placeFailure: failure } : {}), command: shaped.command, args: shaped.args };
|
|
102
|
+
}
|
|
103
|
+
function shapeForContainer(container, projectRoot, command, args, env) {
|
|
104
|
+
const lookup = containerProjectRoot(container, projectRoot);
|
|
105
|
+
// `/` is not a working directory anything would succeed in, and that is the
|
|
106
|
+
// point: the lookup has already failed, every caller of consequence has been
|
|
107
|
+
// through `ensurePlaceIsUsable`, and docker's own refusal is a better answer
|
|
108
|
+
// than a path this connector made up.
|
|
109
|
+
const workingDirectory = lookup.status === 'ok' ? lookup.projectRoot : '/';
|
|
110
|
+
return dockerExec(container, workingDirectory, env, command, args);
|
|
111
|
+
}
|
|
112
|
+
// Can work happen here at all? Asked before anything is written and before
|
|
113
|
+
// anything is uploaded (spec 36, criterion 7), because the alternative is a
|
|
114
|
+
// green run whose evidence disappears with the container.
|
|
115
|
+
//
|
|
116
|
+
// Returns the sentence to stop with, or null when the place is usable. Not a
|
|
117
|
+
// throw: two callers want to stop, one — the map — wants to degrade quietly, and
|
|
118
|
+
// the difference belongs to them.
|
|
119
|
+
export function placeProblem(projectRoot) {
|
|
120
|
+
const place = placeOf(projectRoot);
|
|
121
|
+
if (place.kind === 'local')
|
|
122
|
+
return null;
|
|
123
|
+
return explain(place.container, containerProjectRoot(place.container, projectRoot));
|
|
124
|
+
}
|
|
125
|
+
function explain(container, lookup) {
|
|
126
|
+
switch (lookup.status) {
|
|
127
|
+
case 'ok':
|
|
128
|
+
return null;
|
|
129
|
+
case 'no_docker':
|
|
130
|
+
return (`This project is set to run its tests inside the container \`${container}\`, but \`docker\` is not ` +
|
|
131
|
+
'available on this machine — either it is not installed or its daemon is not running. Start Docker, ' +
|
|
132
|
+
`or remove \`"exec"\` from ${CONFIG_HINT} to run everything here instead.`);
|
|
133
|
+
case 'no_container':
|
|
134
|
+
return (`This project is set to run its tests inside the container \`${container}\`, and no container of that ` +
|
|
135
|
+
'name exists here. Check the name with `docker ps --format "{{.Names}}"` and correct it in ' +
|
|
136
|
+
`${CONFIG_HINT}.`);
|
|
137
|
+
case 'not_running':
|
|
138
|
+
return (`The container \`${container}\` exists but is not running, and this project's tests are set to run ` +
|
|
139
|
+
`inside it. Start it (\`docker start ${container}\`, or \`docker compose up -d\`), then run this again.`);
|
|
140
|
+
case 'not_mounted':
|
|
141
|
+
return (`The container \`${container}\` is running, but this project's folder is not mounted into it — the ` +
|
|
142
|
+
'code inside it was copied in when the image was built. Unitbob cannot work that way: it writes the ' +
|
|
143
|
+
'suite here and the run has to read it there, and a report written inside a container that is not ' +
|
|
144
|
+
'sharing this folder disappears with the container. Mount the project into the container (a `volumes:` ' +
|
|
145
|
+
'entry in your compose file) and run this again. Nothing was written and nothing was uploaded.');
|
|
146
|
+
case 'unreadable':
|
|
147
|
+
return (`Could not ask docker about the container \`${container}\`, which is where this project's tests are ` +
|
|
148
|
+
`set to run: ${lookup.detail}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const CONFIG_HINT = '`.unitbob.json`';
|
|
152
|
+
// "Can this be started at all?" — asked of the same place that will start it.
|
|
153
|
+
//
|
|
154
|
+
// Kept next to `runInProject` rather than left at its old call site, because a
|
|
155
|
+
// check that asks this machine while the run happens somewhere else is a check
|
|
156
|
+
// that predicts the wrong thing. It is the same reason `runnerAvailable` already
|
|
157
|
+
// asks the same interpreters, in the same order, that the run itself asks.
|
|
158
|
+
// One known limitation, written down rather than hidden: the answer is a
|
|
159
|
+
// boolean, so a container that died between the preflight and this question
|
|
160
|
+
// comes back as "no, that cannot be started" — the same conflation `place_failed`
|
|
161
|
+
// was added to the boot check to stop making. It stays a boolean because every
|
|
162
|
+
// caller of `locateRunner` is synchronous and takes yes or no; what keeps it from
|
|
163
|
+
// being the ordinary case is `placeProblem`, which runs first for every command
|
|
164
|
+
// that would act on the answer.
|
|
165
|
+
export function commandSucceedsInProject(projectRoot, command, args) {
|
|
166
|
+
const place = placeOf(projectRoot);
|
|
167
|
+
const shaped = place.kind === 'local'
|
|
168
|
+
? { command, args }
|
|
169
|
+
: shapeForContainer(place.container, projectRoot, command, args, {});
|
|
170
|
+
const result = spawnSync(shaped.command, shaped.args, {
|
|
171
|
+
cwd: projectRoot,
|
|
172
|
+
// A round trip through the docker daemon is not the same wait as starting a
|
|
173
|
+
// local binary: the client has to reach the daemon, and the daemon has to
|
|
174
|
+
// start a process in a container that may be busy running the application.
|
|
175
|
+
// Ten seconds is right for `python3 -m pytest --version` here and is the
|
|
176
|
+
// kind of budget that turns a slow machine into "no runner available".
|
|
177
|
+
timeout: place.kind === 'local' ? 10_000 : 60_000,
|
|
178
|
+
env: { ...process.env },
|
|
179
|
+
});
|
|
180
|
+
return result.status === 0;
|
|
181
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What to say when this project's test toolchain cannot be started (spec 36, §7).
|
|
2
|
+
//
|
|
3
|
+
// Half the value of the whole spec is here rather than in the adapter. A field
|
|
4
|
+
// nobody discovers is a field nobody sets: the vibecoder whose gems live only
|
|
5
|
+
// inside a container reads "Bundler failed to provision rspec-rails under
|
|
6
|
+
// .unitbob/runners." — a sentence about a symptom, which sounds like "your
|
|
7
|
+
// project is broken" and sends them off inventing workarounds.
|
|
8
|
+
//
|
|
9
|
+
// Two situations, one question, so they live in one function: the place is not
|
|
10
|
+
// configured and something on this machine looks like the answer, or the place
|
|
11
|
+
// is configured and the advice above just told somebody to run a command in the
|
|
12
|
+
// wrong place.
|
|
13
|
+
import { containersHolding } from "./docker.js";
|
|
14
|
+
import { placeOf } from "./place.js";
|
|
15
|
+
// The sentence to add to a message that has already decided to stop, or null
|
|
16
|
+
// when there is nothing worth saying.
|
|
17
|
+
//
|
|
18
|
+
// Only ever called on the failure path. On a successful run `docker` is not
|
|
19
|
+
// asked anything, so nobody without Docker pays for this and nobody with Docker
|
|
20
|
+
// waits for it.
|
|
21
|
+
export function placeAdvice(projectRoot) {
|
|
22
|
+
const place = placeOf(projectRoot);
|
|
23
|
+
// Already running in a container. Then the problem is not "which place" — it
|
|
24
|
+
// is that every manual command above (`bundle install`, `npm install --prefix
|
|
25
|
+
// .unitbob/runners`, `gem install bundler`, "activate your virtualenv") reads
|
|
26
|
+
// as "do this in your project folder", and doing it here would change nothing.
|
|
27
|
+
// One sentence covers all of them, and none of them has to be rewritten.
|
|
28
|
+
if (place.kind === 'docker') {
|
|
29
|
+
return (`This project's tests run inside the container \`${place.container}\`, so any command suggested above ` +
|
|
30
|
+
`has to be run in there, not here: \`docker exec -it ${place.container} <command>\`.\n` +
|
|
31
|
+
// The two edge cases the spec asks to be named rather than solved. Both
|
|
32
|
+
// look like an ordinary install failure and neither is: nothing in the
|
|
33
|
+
// output says "the mount is read-only" or "these files belong to someone
|
|
34
|
+
// else", so the reader is left staring at a permission error with no
|
|
35
|
+
// reason to suspect the container is why.
|
|
36
|
+
'If it failed on permissions rather than on a missing package, two things are worth checking: that ' +
|
|
37
|
+
'the project is not mounted read-only, and — on a Linux host — that the files under `.unitbob/` are ' +
|
|
38
|
+
'still yours, since a container writing there creates them as `root`.');
|
|
39
|
+
}
|
|
40
|
+
const candidates = containersHolding(projectRoot);
|
|
41
|
+
if (candidates.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
const lines = candidates.map((found) => ` \`${found.name}\` — it sees this project as \`${found.projectRoot}\`\n` +
|
|
44
|
+
` "exec": {"docker": {"container": ${JSON.stringify(found.name)}}}`);
|
|
45
|
+
// Several candidates are listed and none is picked. Guessing between `web` and
|
|
46
|
+
// `worker` is wrong on the first project that has both, and a wrong guess here
|
|
47
|
+
// is silent: the suite runs somewhere nobody meant it to.
|
|
48
|
+
return ("This project's tests do not run on this machine, and they may not be meant to. " +
|
|
49
|
+
`${candidates.length === 1 ? 'A running container already has this project mounted' : 'These running containers already have this project mounted'}` +
|
|
50
|
+
`:\n\n${lines.join('\n')}\n\n` +
|
|
51
|
+
`Add the line under the container you run your tests in to \`.unitbob.json\`, then run this again.`);
|
|
52
|
+
}
|