unitbob 0.5.1 → 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 +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/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// An installed environment belongs to the place that installed it (spec 36, §6).
|
|
2
|
+
//
|
|
3
|
+
// The invariant "files on the host, processes where the dependencies live" is
|
|
4
|
+
// true of text and false of installed packages. `.unitbob/runners/.venv` and
|
|
5
|
+
// `.unitbob/behavioral/node_modules` sit under the project, which is to say
|
|
6
|
+
// inside the mounted folder, which is to say both sides can see them — and they
|
|
7
|
+
// were built for one operating system. A virtualenv built on macOS is not a
|
|
8
|
+
// virtualenv inside a Linux container, however plainly it is there.
|
|
9
|
+
//
|
|
10
|
+
// And the connector cannot tell by looking: readiness is decided by a file being
|
|
11
|
+
// on disk (`provision.ts`, `existsSync(venvPython)`), never by starting it. So
|
|
12
|
+
// the main scenario of this whole spec ends badly without the mark below —
|
|
13
|
+
// somebody hits the wall on this machine (a `.venv` gets created), reads the
|
|
14
|
+
// hint, adds `exec`, runs again, and inside the container the macOS interpreter
|
|
15
|
+
// is taken for a finished environment. What follows is `pip` failing to start,
|
|
16
|
+
// its stderr discarded, and the advice to run a command that cannot run either.
|
|
17
|
+
//
|
|
18
|
+
// The cure is one mark, not a directory scheme per place: cheap to write, and
|
|
19
|
+
// nothing else in the tree has to know about it.
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { BEHAVIORAL_DIR, RUNNER_ENVIRONMENT_ENTRIES } from "../files/behavioral.js";
|
|
23
|
+
import { describePlaceId, placeId, placeOf } from "./place.js";
|
|
24
|
+
import { SIDECAR_DIR } from "./toolchain.js";
|
|
25
|
+
const PLACE_FILE = '.unitbob/.place';
|
|
26
|
+
// Throw away any runner environment that was built somewhere else, and record
|
|
27
|
+
// where this one is being built. Returns a line worth printing when something
|
|
28
|
+
// was actually removed, and null when nothing was.
|
|
29
|
+
//
|
|
30
|
+
// Called where an environment can be built again — `suite-prepare` — and
|
|
31
|
+
// nowhere else. Clearing it anywhere else would leave a run with no runner and
|
|
32
|
+
// no way to get one.
|
|
33
|
+
export function alignRunnerEnvironmentWithPlace(projectRoot) {
|
|
34
|
+
const current = placeId(placeOf(projectRoot));
|
|
35
|
+
const marked = readMark(projectRoot);
|
|
36
|
+
if (marked === current)
|
|
37
|
+
return null;
|
|
38
|
+
// A project that has never been marked was provisioned before this existed,
|
|
39
|
+
// which means it was provisioned on this machine. Saying so out loud is what
|
|
40
|
+
// makes "the mark is missing" a fact rather than a mystery.
|
|
41
|
+
const previous = marked ?? 'local';
|
|
42
|
+
const removed = previous === current ? [] : removeRunnerEnvironment(projectRoot);
|
|
43
|
+
writeMark(projectRoot, current);
|
|
44
|
+
if (removed.length === 0)
|
|
45
|
+
return null;
|
|
46
|
+
return (`The test runner installed under \`.unitbob/\` was built for ${describePlaceId(previous)}, and this run ` +
|
|
47
|
+
`happens in ${describePlaceId(current)}. An installed package is not portable between the two, so it was ` +
|
|
48
|
+
`removed and will be installed again here (${removed.join(', ')}). Your generated suite was not touched.`);
|
|
49
|
+
}
|
|
50
|
+
// The same question, asked by a command that cannot install anything — `check`
|
|
51
|
+
// and `run-local`. It refuses instead of clearing: throwing the environment away
|
|
52
|
+
// here would leave the run with no runner and no way to get one, which is a
|
|
53
|
+
// worse answer than naming the one command that fixes it.
|
|
54
|
+
//
|
|
55
|
+
// Criterion 6 is about not *accepting* a foreign environment as ready, and this
|
|
56
|
+
// is the half of it that lives outside `suite-prepare`. Without it, adding
|
|
57
|
+
// `exec` to a project whose suite already exists and running `check` starts a
|
|
58
|
+
// macOS interpreter inside a Linux container and reports whatever comes out.
|
|
59
|
+
export function runnerEnvironmentPlaceProblem(projectRoot) {
|
|
60
|
+
const current = placeId(placeOf(projectRoot));
|
|
61
|
+
const previous = readMark(projectRoot) ?? 'local';
|
|
62
|
+
if (previous === current)
|
|
63
|
+
return null;
|
|
64
|
+
// Nothing installed, nothing stale. A project whose runner has never been
|
|
65
|
+
// built has an honest reason to be here — `check` on a fresh checkout — and
|
|
66
|
+
// the missing runner is reported by the runner, in its own words.
|
|
67
|
+
const installed = installedRunnerEnvironment(projectRoot);
|
|
68
|
+
if (installed.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return (`The test runner installed under \`.unitbob/\` was built for ${describePlaceId(previous)}, and this run ` +
|
|
71
|
+
`happens in ${describePlaceId(current)} (${installed.join(', ')}). An installed package is not portable ` +
|
|
72
|
+
'between the two, and this command installs nothing. Run `unitbob suite-prepare` to build the runner ' +
|
|
73
|
+
'where the run now happens, then try this again.');
|
|
74
|
+
}
|
|
75
|
+
function readMark(projectRoot) {
|
|
76
|
+
try {
|
|
77
|
+
const mark = readFileSync(join(projectRoot, PLACE_FILE), 'utf8').trim();
|
|
78
|
+
return mark.length > 0 ? mark : null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function writeMark(projectRoot, id) {
|
|
85
|
+
mkdirSync(join(projectRoot, '.unitbob'), { recursive: true });
|
|
86
|
+
writeFileSync(join(projectRoot, PLACE_FILE), `${id}\n`);
|
|
87
|
+
}
|
|
88
|
+
// The structural sidecar whole, and from the behavioral root only the entries
|
|
89
|
+
// that are an installed environment. That list already exists and already means
|
|
90
|
+
// exactly "this was installed, it is not generated text", so the suite the host
|
|
91
|
+
// wrote survives.
|
|
92
|
+
//
|
|
93
|
+
// One list, read by both the command that clears and the command that only
|
|
94
|
+
// complains, so the two can never disagree about what an installed environment
|
|
95
|
+
// is.
|
|
96
|
+
function installedRunnerEnvironment(projectRoot) {
|
|
97
|
+
const found = [];
|
|
98
|
+
if (existsSync(join(projectRoot, SIDECAR_DIR)))
|
|
99
|
+
found.push(`${SIDECAR_DIR}/`);
|
|
100
|
+
for (const entry of new Set(Object.values(RUNNER_ENVIRONMENT_ENTRIES).flatMap((set) => [...set]))) {
|
|
101
|
+
if (existsSync(join(projectRoot, BEHAVIORAL_DIR, entry)))
|
|
102
|
+
found.push(`${BEHAVIORAL_DIR}/${entry}`);
|
|
103
|
+
}
|
|
104
|
+
return found.sort();
|
|
105
|
+
}
|
|
106
|
+
function removeRunnerEnvironment(projectRoot) {
|
|
107
|
+
const installed = installedRunnerEnvironment(projectRoot);
|
|
108
|
+
for (const entry of installed)
|
|
109
|
+
rmSync(join(projectRoot, entry), { recursive: true, force: true });
|
|
110
|
+
return installed;
|
|
111
|
+
}
|
package/dist/runner/precheck.js
CHANGED
|
@@ -157,6 +157,41 @@ function rubyBehavioralPrecheck(projectRoot) {
|
|
|
157
157
|
'like Rails (no `rails` gem found in Gemfile).',
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
|
+
// What the behavioral branch's environment is, before a single step is written
|
|
161
|
+
// (spec 35-1, criterion 2). None of the three BDD runners reads the project's own
|
|
162
|
+
// test bootstrap, so on every stack nothing a project's test setup switches on is
|
|
163
|
+
// on here — and a worker who assumes otherwise writes a test that quietly goes
|
|
164
|
+
// out to the real network.
|
|
165
|
+
//
|
|
166
|
+
// One sentence per runner, in that runner's own terms and stating only what is
|
|
167
|
+
// true of it. A shared sentence would have to be vague enough to fit all three,
|
|
168
|
+
// and vague is how the fact went unsaid in the first place.
|
|
169
|
+
const BEHAVIORAL_HARNESS_NOTICE = {
|
|
170
|
+
cucumber: 'Cucumber loads neither `spec/rails_helper.rb` nor `spec/support/**` — whatever your RSpec ' +
|
|
171
|
+
'setup switches on is off here. The connector-owned World file turns on the part that is the ' +
|
|
172
|
+
'same in every Rails app: WebMock is on and outgoing HTTP is blocked (localhost still ' +
|
|
173
|
+
'reachable), Sidekiq is in fake mode, ActiveJob is on the test adapter, and the default URL ' +
|
|
174
|
+
'host is fixed. Everything that depends on this application — signing in, factories, reading ' +
|
|
175
|
+
'props, stubbing a provider — is yours to write in shared steps.',
|
|
176
|
+
'cucumber-js': 'cucumber-js loads none of this project\'s test bootstrap — not your Vitest setup files, and ' +
|
|
177
|
+
'not `features/support/`, which the connector\'s explicit `--require` switches off. Whatever ' +
|
|
178
|
+
'your test setup switches on is off here. The connector-owned World file settles the one ' +
|
|
179
|
+
'thing that is the same in every JavaScript project: a connection that would leave this ' +
|
|
180
|
+
'machine is refused, localhost included in neither direction — it stays reachable. There is ' +
|
|
181
|
+
'no project-wide job runner or default host to fix, so nothing else is assumed. Signing in, ' +
|
|
182
|
+
'fixtures and seeding are yours to write in shared steps.',
|
|
183
|
+
'pytest-bdd': 'pytest runs here against the connector\'s own config (`-c`), so this project\'s pytest ' +
|
|
184
|
+
'settings — addopts, markers, plugin configuration — do not apply, and the only conftest.py ' +
|
|
185
|
+
'files loaded are those from the repository root down to `step_definitions/`: your ' +
|
|
186
|
+
'`tests/conftest.py` fixtures are not available. The connector-owned `conftest.py` one level ' +
|
|
187
|
+
'above `step_definitions/` settles the one thing that is the same in every Python project: a ' +
|
|
188
|
+
'connection that would leave this machine is refused, and localhost stays reachable. Your own ' +
|
|
189
|
+
'`step_definitions/conftest.py` is untouched and is where your shared fixtures belong.',
|
|
190
|
+
};
|
|
191
|
+
export function behavioralHarnessNotice(runner) {
|
|
192
|
+
const notice = BEHAVIORAL_HARNESS_NOTICE[runner];
|
|
193
|
+
return notice ? `\n${notice}\n` : null;
|
|
194
|
+
}
|
|
160
195
|
function jsBehavioralPrecheck(projectRoot) {
|
|
161
196
|
if (existsSync(join(projectRoot, 'package.json')))
|
|
162
197
|
return { ok: true };
|
package/dist/runner/provision.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { executable } from "../proc.js";
|
|
4
|
+
import { BEHAVIORAL_DIR } from "../files/behavioral.js";
|
|
5
|
+
import { runInProject } from "./place.js";
|
|
6
|
+
import { commandFileOnHost, defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
|
|
5
7
|
// How long a local setup step may take before we stop waiting. Provisioning a
|
|
6
8
|
// runner and loading a cold Rails test environment sit in the same ballpark —
|
|
7
9
|
// tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
|
|
@@ -12,11 +14,14 @@ export const PROVISION_TIMEOUT_MS = 120_000;
|
|
|
12
14
|
// package index. Two minutes is a normal figure for it, so it gets its own
|
|
13
15
|
// budget instead of borrowing one sized for a single install.
|
|
14
16
|
export const DEPENDENCY_INSTALL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
17
|
+
// `cwd` is the project root at every call site, and that is what decides where
|
|
18
|
+
// the install happens: a sidecar built by this machine is no use inside a
|
|
19
|
+
// container, and one built inside a container is no use here (see
|
|
20
|
+
// `runner/placeEnvironment.ts`).
|
|
15
21
|
const defaultDeps = {
|
|
16
|
-
runCmd: (command, args, options) =>
|
|
17
|
-
cwd: options.cwd,
|
|
22
|
+
runCmd: (command, args, options) => runInProject(options.cwd, command, args, {
|
|
18
23
|
timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS,
|
|
19
|
-
env:
|
|
24
|
+
env: options.env,
|
|
20
25
|
}),
|
|
21
26
|
};
|
|
22
27
|
export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
|
|
@@ -93,8 +98,8 @@ const VENV_BUILDERS = [
|
|
|
93
98
|
// the project declares them in a requirements file, the application's own
|
|
94
99
|
// packages — and deliberately nothing else. See `VENV_BUILDERS`.
|
|
95
100
|
async function provisionPytest(projectRoot, deps) {
|
|
96
|
-
const venvDir =
|
|
97
|
-
const venvPython =
|
|
101
|
+
const venvDir = `${SIDECAR_DIR}/.venv`;
|
|
102
|
+
const venvPython = `${venvDir}/bin/python`;
|
|
98
103
|
const built = await buildPythonEnvironment(projectRoot, venvDir, deps);
|
|
99
104
|
if (!built.created) {
|
|
100
105
|
return {
|
|
@@ -126,14 +131,17 @@ async function provisionPytest(projectRoot, deps) {
|
|
|
126
131
|
// installs all three from wheels in seconds. So when the requirements will not
|
|
127
132
|
// go in, the environment is rebuilt with the next builder rather than handed
|
|
128
133
|
// over half-empty. Found 2026-08-12.
|
|
134
|
+
// `venvDir` is relative to the project root, like every other path that reaches
|
|
135
|
+
// a command: the interpreter is built and then started by the place, and only
|
|
136
|
+
// the existence checks below are the host's (spec 36, §4.2).
|
|
129
137
|
async function buildPythonEnvironment(projectRoot, venvDir, deps) {
|
|
130
|
-
const venvPython =
|
|
138
|
+
const venvPython = `${venvDir}/bin/python`;
|
|
131
139
|
// The project's own statement of what it needs. It is also the only test of
|
|
132
140
|
// whether an environment is any use: one the application's packages will not
|
|
133
141
|
// install into is the wrong environment, however well it was built.
|
|
134
142
|
const requirements = ['requirements.txt', 'requirements/base.txt', 'requirements-dev.txt']
|
|
135
143
|
.find((name) => existsSync(join(projectRoot, name)));
|
|
136
|
-
let created = existsSync(venvPython);
|
|
144
|
+
let created = existsSync(join(projectRoot, venvPython));
|
|
137
145
|
let requirementsOk = requirements === undefined;
|
|
138
146
|
let failure;
|
|
139
147
|
for (const [index, builder] of VENV_BUILDERS.entries()) {
|
|
@@ -162,7 +170,7 @@ async function buildPythonEnvironment(projectRoot, venvDir, deps) {
|
|
|
162
170
|
// application says far more than no suite at all.
|
|
163
171
|
if (index === VENV_BUILDERS.length - 1)
|
|
164
172
|
break;
|
|
165
|
-
rmSync(venvDir, { recursive: true, force: true });
|
|
173
|
+
rmSync(join(projectRoot, venvDir), { recursive: true, force: true });
|
|
166
174
|
created = false;
|
|
167
175
|
}
|
|
168
176
|
if (!created)
|
|
@@ -178,7 +186,7 @@ async function buildPythonEnvironment(projectRoot, venvDir, deps) {
|
|
|
178
186
|
// "pg_config not found") that decides what they do next.
|
|
179
187
|
return {
|
|
180
188
|
created,
|
|
181
|
-
requirementsNote: `installing ${requirements} into ${
|
|
189
|
+
requirementsNote: `installing ${requirements} into ${venvDir} did not finish on any Python ` +
|
|
182
190
|
`available here — the suite may not be able to import the application.` +
|
|
183
191
|
(failure ? ` The install said: ${failure}` : ''),
|
|
184
192
|
};
|
|
@@ -200,7 +208,7 @@ function noDependencySourceNote(projectRoot, venvDir) {
|
|
|
200
208
|
const where = declared.length
|
|
201
209
|
? `this project declares its dependencies in ${declared.join(' and ')}, which Unitbob does not install from yet`
|
|
202
210
|
: 'no requirements.txt, pyproject.toml or Pipfile was found';
|
|
203
|
-
return (`the application's own packages are not installed into ${
|
|
211
|
+
return (`the application's own packages are not installed into ${venvDir} — ${where}. ` +
|
|
204
212
|
'The suite can start, but it may not be able to import the application.');
|
|
205
213
|
}
|
|
206
214
|
// Install into the sidecar environment, whichever tool built it.
|
|
@@ -270,6 +278,23 @@ async function provisionVitest(projectRoot, deps) {
|
|
|
270
278
|
}
|
|
271
279
|
return { status: 'provisioned' };
|
|
272
280
|
}
|
|
281
|
+
// Frozen bundler, turned off for our own Gemfile and for nothing else
|
|
282
|
+
// (spec 36, task 2.6).
|
|
283
|
+
//
|
|
284
|
+
// The sidecar Gemfile adds a gem the project's lockfile has never heard of —
|
|
285
|
+
// that is its entire job — and under `frozen` or `deployment` bundler refuses
|
|
286
|
+
// exactly that: "the dependencies in your gemfile changed, but the lockfile
|
|
287
|
+
// can't be updated because frozen mode is set". Nothing installs, and the
|
|
288
|
+
// vibecoder is told to commit a file the connector wrote.
|
|
289
|
+
//
|
|
290
|
+
// Measured, not reasoned about (2026-08-17, `ruby:3.3-slim`). The project's own
|
|
291
|
+
// `.bundle/config` does *not* reach here: bundler reads app config relative to
|
|
292
|
+
// the Gemfile it was given, which is `.unitbob/runners/`. The environment does,
|
|
293
|
+
// and a dev or production image setting `BUNDLE_DEPLOYMENT=1` is ordinary. So
|
|
294
|
+
// the override is scoped to the one install whose Gemfile we wrote; the
|
|
295
|
+
// project's own bundler settings are never touched, and no other bundler
|
|
296
|
+
// invocation carries this.
|
|
297
|
+
const UNFROZEN_SIDECAR = { BUNDLE_FROZEN: 'false', BUNDLE_DEPLOYMENT: 'false' };
|
|
273
298
|
// A sidecar Gemfile that inherits the project's own, plus rspec-rails. Bundler
|
|
274
299
|
// resolves the two together, so the application's gems come with it — the same
|
|
275
300
|
// arrangement the Cucumber sidecar has used since spec 32-1, and the reason the
|
|
@@ -283,12 +308,11 @@ async function provisionRspec(projectRoot, deps) {
|
|
|
283
308
|
// Cucumber sidecar below: without it bundler re-resolves the whole graph and
|
|
284
309
|
// hands the sidecar versions the project does not run.
|
|
285
310
|
copyLockIfPresent(projectRoot, sidecarPath(projectRoot, 'Gemfile.lock'));
|
|
286
|
-
const
|
|
287
|
-
const command = existsSync(localBundle) ? localBundle : 'bundle';
|
|
311
|
+
const command = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
|
|
288
312
|
const result = await deps
|
|
289
313
|
.runCmd(command, ['install'], {
|
|
290
314
|
cwd: projectRoot,
|
|
291
|
-
env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile
|
|
315
|
+
env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile`, ...UNFROZEN_SIDECAR },
|
|
292
316
|
timeoutMs: DEPENDENCY_INSTALL_TIMEOUT_MS,
|
|
293
317
|
})
|
|
294
318
|
.catch((err) => ({ code: 1, stdout: '', stderr: String(err) }));
|
|
@@ -328,7 +352,13 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
|
328
352
|
const sidecarGemfile = join(behavioralDir, 'Gemfile');
|
|
329
353
|
const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
|
|
330
354
|
'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
|
|
331
|
-
'gem "cucumber", "~> 9.0", require: false\n'
|
|
355
|
+
'gem "cucumber", "~> 9.0", require: false\n' +
|
|
356
|
+
// The connector-owned World blocks outgoing HTTP (spec 35-1), and it can only
|
|
357
|
+
// do that if webmock resolves here. A project that already carries the gem
|
|
358
|
+
// keeps its own version, because bundler starts from the project's own
|
|
359
|
+
// resolution. A project that does not would otherwise get a World promising a
|
|
360
|
+
// block it silently never performs — the exact shape of failure 35-1 closes.
|
|
361
|
+
'gem "webmock", require: false\n';
|
|
332
362
|
if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
|
|
333
363
|
writeFileSync(sidecarGemfile, sidecarContent);
|
|
334
364
|
}
|
|
@@ -351,11 +381,14 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
|
351
381
|
if (existsSync(projectLock)) {
|
|
352
382
|
writeFileSync(join(behavioralDir, 'Gemfile.lock'), readFileSync(projectLock, 'utf8'));
|
|
353
383
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
384
|
+
// The Cucumber sidecar has the same shape and the same problem as the rspec
|
|
385
|
+
// one: it adds gems the project's lockfile does not carry. See UNFROZEN_SIDECAR.
|
|
386
|
+
const gemfileRel = `${BEHAVIORAL_DIR}/Gemfile`;
|
|
387
|
+
const env = { BUNDLE_GEMFILE: gemfileRel, ...UNFROZEN_SIDECAR };
|
|
388
|
+
// Try project local bin/bundle, then bundle. Relative with a slash: the
|
|
389
|
+
// working directory is the project root, and a bare name would be looked up on
|
|
390
|
+
// PATH instead.
|
|
391
|
+
const cmd = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
|
|
359
392
|
const result = await deps.runCmd(cmd, ['install'], { cwd: projectRoot, env }).catch((err) => ({
|
|
360
393
|
code: 1,
|
|
361
394
|
stdout: '',
|
|
@@ -371,9 +404,13 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
|
371
404
|
};
|
|
372
405
|
}
|
|
373
406
|
async function provisionPython(projectRoot, behavioralDir, deps) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
407
|
+
// Two forms of one path, and the split is the rule of spec 36, §4.2: what a
|
|
408
|
+
// command names is relative, because the command runs where the dependencies
|
|
409
|
+
// live; what we test for existence is the host's, because that is where the
|
|
410
|
+
// files are.
|
|
411
|
+
const venvDir = `${BEHAVIORAL_DIR}/.venv`;
|
|
412
|
+
const venvPython = `${venvDir}/bin/python`;
|
|
413
|
+
const venvPytest = commandFileOnHost(projectRoot, `${venvDir}/bin/pytest`);
|
|
377
414
|
// The behavioral suite drives the application, so its environment needs the
|
|
378
415
|
// application in it — the same requirement, and now the same treatment, as the
|
|
379
416
|
// structural peer. It used to be built with `--system-site-packages` and given
|
|
@@ -384,7 +421,7 @@ async function provisionPython(projectRoot, behavioralDir, deps) {
|
|
|
384
421
|
if (!built.created) {
|
|
385
422
|
return {
|
|
386
423
|
status: 'fixable',
|
|
387
|
-
message: `Failed to create virtual environment under ${
|
|
424
|
+
message: `Failed to create virtual environment under ${venvDir}.`,
|
|
388
425
|
checklist: ['Install python3-venv or uv: `python3 -m venv --help` or `pip install uv`.'],
|
|
389
426
|
};
|
|
390
427
|
}
|
|
@@ -392,7 +429,7 @@ async function provisionPython(projectRoot, behavioralDir, deps) {
|
|
|
392
429
|
if (!installed && !existsSync(venvPytest)) {
|
|
393
430
|
return {
|
|
394
431
|
status: 'fixable',
|
|
395
|
-
message: `Failed to install pytest-bdd into ${
|
|
432
|
+
message: `Failed to install pytest-bdd into ${venvDir}.`,
|
|
396
433
|
checklist: [`Run \`${venvPython} -m pip install pytest-bdd\` manually to provision the runner.`],
|
|
397
434
|
};
|
|
398
435
|
}
|
|
@@ -400,9 +437,6 @@ async function provisionPython(projectRoot, behavioralDir, deps) {
|
|
|
400
437
|
? { status: 'provisioned', checklist: [built.requirementsNote] }
|
|
401
438
|
: { status: 'provisioned' };
|
|
402
439
|
}
|
|
403
|
-
function relativeVenv(projectRoot, venvDir) {
|
|
404
|
-
return venvDir.startsWith(projectRoot) ? venvDir.slice(projectRoot.length + 1) : venvDir;
|
|
405
|
-
}
|
|
406
440
|
async function provisionJs(projectRoot, behavioralDir, deps) {
|
|
407
441
|
const sidecarPkg = join(behavioralDir, 'package.json');
|
|
408
442
|
const sidecarContent = JSON.stringify({
|
package/dist/runner/pytest.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { runProcess } from "../proc.js";
|
|
4
3
|
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
4
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
5
|
import { locateRunner } from "./toolchain.js";
|
|
6
|
-
import {
|
|
6
|
+
import { clearReport, readFreshReport } from "./types.js";
|
|
7
7
|
export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
8
|
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
|
|
9
9
|
// A minimal runtime config, created or overwritten before each run and passed
|
|
@@ -17,7 +17,7 @@ export const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
|
17
17
|
// JUnit XML report goes to --junit-xml, not stdout. The command is
|
|
18
18
|
// connector-owned: the suite artifact never carries a command string.
|
|
19
19
|
//
|
|
20
|
-
// Every file of the branch is named positionally (spec
|
|
20
|
+
// Every file of the branch is named positionally (spec 43, §6.5) — a branch is
|
|
21
21
|
// one file per assignment now, and pytest takes as many paths as it is given.
|
|
22
22
|
//
|
|
23
23
|
// Which pytest is a single question answered in one place (`locateRunner`), so
|
|
@@ -36,16 +36,15 @@ export async function runPytestSuite(projectRoot, suitePaths) {
|
|
|
36
36
|
...suitePaths,
|
|
37
37
|
`--junit-xml=${PYTEST_RESULT_FILE}`,
|
|
38
38
|
];
|
|
39
|
-
const
|
|
40
|
-
|
|
39
|
+
const reportPath = join(projectRoot, PYTEST_RESULT_FILE);
|
|
40
|
+
const survivor = clearReport(reportPath);
|
|
41
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
41
42
|
timeoutMs: PYTEST_TIMEOUT_MS,
|
|
42
|
-
env: { ...
|
|
43
|
+
env: { ...located?.env, UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
|
|
43
44
|
});
|
|
44
45
|
return {
|
|
45
|
-
...
|
|
46
|
-
command,
|
|
47
|
-
args,
|
|
46
|
+
...run,
|
|
48
47
|
resultPath: PYTEST_RESULT_FILE,
|
|
49
|
-
report:
|
|
48
|
+
report: readFreshReport(reportPath, survivor),
|
|
50
49
|
};
|
|
51
50
|
}
|
|
@@ -14,6 +14,15 @@ import os
|
|
|
14
14
|
_UNITBOB_REPORT = {"version": 1, "scenarios": []}
|
|
15
15
|
_UNITBOB_CURRENT = {}
|
|
16
16
|
|
|
17
|
+
# Resolved once, at import, and never again. The connector passes this path
|
|
18
|
+
# relative to the project root — a relative path means the same thing whether the
|
|
19
|
+
# run happens on this machine or inside a container, and an absolute host path
|
|
20
|
+
# would name a directory the run cannot see. The working directory is the project
|
|
21
|
+
# root when the plugin loads, so a fixture that changes directory later cannot
|
|
22
|
+
# move the report out from under us.
|
|
23
|
+
_UNITBOB_OUT = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
|
|
24
|
+
_UNITBOB_OUT = os.path.abspath(_UNITBOB_OUT) if _UNITBOB_OUT else None
|
|
25
|
+
|
|
17
26
|
|
|
18
27
|
def pytest_bdd_before_scenario(request, feature, scenario):
|
|
19
28
|
_UNITBOB_CURRENT[id(scenario)] = {
|
|
@@ -55,8 +64,7 @@ def pytest_bdd_after_scenario(request, feature, scenario):
|
|
|
55
64
|
|
|
56
65
|
|
|
57
66
|
def pytest_sessionfinish(session, exitstatus):
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
with open(out, "w") as handle:
|
|
67
|
+
if _UNITBOB_OUT:
|
|
68
|
+
with open(_UNITBOB_OUT, "w") as handle:
|
|
61
69
|
json.dump(_UNITBOB_REPORT, handle)
|
|
62
70
|
`;
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { runProcess } from "../proc.js";
|
|
3
2
|
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
3
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
4
4
|
import { locateRunner } from "./toolchain.js";
|
|
5
|
-
import {
|
|
5
|
+
import { clearReport, readFreshReport } from "./types.js";
|
|
6
6
|
export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
7
|
// Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
|
|
8
8
|
// green→red flip can never come from run-order nondeterminism. It does not inherit
|
|
@@ -18,13 +18,15 @@ export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
|
|
|
18
18
|
// corrupt it.
|
|
19
19
|
//
|
|
20
20
|
// `suitePaths` is every file of the branch in the suite blob's own
|
|
21
|
-
// project-relative form (spec
|
|
21
|
+
// project-relative form (spec 43, §6.5). Named one by one rather than as a
|
|
22
22
|
// directory: the artifact already says exactly which files it is, while a
|
|
23
23
|
// directory would also collect whatever else happens to be sitting under the
|
|
24
24
|
// root.
|
|
25
25
|
export async function runRspecSuite(projectRoot, suitePaths) {
|
|
26
26
|
const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
|
|
27
|
-
const
|
|
27
|
+
const reportPath = join(projectRoot, RSPEC_RESULT_FILE);
|
|
28
|
+
const survivor = clearReport(reportPath);
|
|
29
|
+
const run = await invokeRspec(projectRoot, [
|
|
28
30
|
...suitePaths,
|
|
29
31
|
'--options',
|
|
30
32
|
optionsPath,
|
|
@@ -38,11 +40,9 @@ export async function runRspecSuite(projectRoot, suitePaths) {
|
|
|
38
40
|
RSPEC_RESULT_FILE,
|
|
39
41
|
]);
|
|
40
42
|
return {
|
|
41
|
-
...
|
|
42
|
-
command,
|
|
43
|
-
args,
|
|
43
|
+
...run,
|
|
44
44
|
resultPath: RSPEC_RESULT_FILE,
|
|
45
|
-
report:
|
|
45
|
+
report: readFreshReport(reportPath, survivor),
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
// Which rspec — the sidecar Unitbob installed, the project's own `bin/rspec`
|
|
@@ -54,10 +54,12 @@ async function invokeRspec(projectRoot, rspecArgs) {
|
|
|
54
54
|
const located = locateRunner(projectRoot, 'rspec');
|
|
55
55
|
const command = located?.command ?? 'bundle';
|
|
56
56
|
const args = [...(located?.args ?? ['exec', 'rspec']), ...rspecArgs];
|
|
57
|
-
|
|
58
|
-
cwd: projectRoot,
|
|
57
|
+
return runInProject(projectRoot, command, args, {
|
|
59
58
|
timeoutMs: RSPEC_TIMEOUT_MS,
|
|
60
|
-
env: {
|
|
59
|
+
env: {
|
|
60
|
+
...located?.env,
|
|
61
|
+
RAILS_ENV: 'test',
|
|
62
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
63
|
+
},
|
|
61
64
|
});
|
|
62
|
-
return { result, command, args };
|
|
63
65
|
}
|
package/dist/runner/toolchain.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
3
|
import { executable } from "../proc.js";
|
|
4
|
+
import { commandSucceedsInProject } from "./place.js";
|
|
5
5
|
// Where Unitbob keeps a test runner it had to install for itself, together with
|
|
6
6
|
// whatever that runner needs to load the project.
|
|
7
7
|
//
|
|
@@ -15,8 +15,42 @@ export const SIDECAR_DIR = '.unitbob/runners';
|
|
|
15
15
|
export function sidecarPath(projectRoot, ...segments) {
|
|
16
16
|
return join(projectRoot, SIDECAR_DIR, ...segments);
|
|
17
17
|
}
|
|
18
|
+
// The stop that means "nothing here can start this project's test runner"
|
|
19
|
+
// (spec 36, §7.1).
|
|
20
|
+
//
|
|
21
|
+
// It carries no new wording — the four places that throw it say exactly what
|
|
22
|
+
// they said before. All it adds is a name, so that one place at the top can tell
|
|
23
|
+
// this stop apart from "the server did not answer" and "your token was refused",
|
|
24
|
+
// and offer the one piece of advice that only fits this one. Hanging that advice
|
|
25
|
+
// on the individual failure sites instead would have given it to Ruby alone: a
|
|
26
|
+
// pytest project in a container stops somewhere else, with different words, and
|
|
27
|
+
// a vitest one somewhere else again.
|
|
28
|
+
export class ToolchainUnavailableError extends Error {
|
|
29
|
+
projectRoot;
|
|
30
|
+
constructor(message, projectRoot) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'ToolchainUnavailableError';
|
|
33
|
+
this.projectRoot = projectRoot;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// The file a command names, on the host's own filesystem.
|
|
37
|
+
//
|
|
38
|
+
// A command that names a file we own is written relative to the project root, so
|
|
39
|
+
// that it means the same thing wherever it is started (spec 36, §4.2). Asking
|
|
40
|
+
// whether that file exists is a different question and is always the host's:
|
|
41
|
+
// under the invariant the connector's files are on the host, and the place sees
|
|
42
|
+
// the very same ones. A command with no path in it — `bundle`, `python3` — is
|
|
43
|
+
// resolved by the place through its own PATH and is returned unchanged.
|
|
44
|
+
export function commandFileOnHost(projectRoot, command) {
|
|
45
|
+
if (isAbsolute(command) || !command.includes('/'))
|
|
46
|
+
return command;
|
|
47
|
+
return join(projectRoot, command);
|
|
48
|
+
}
|
|
49
|
+
// Asked of the place the run will happen in, never of this machine by default
|
|
50
|
+
// (spec 36, §3). `cwd` is the project root at every call site, which is what
|
|
51
|
+
// says which place that is.
|
|
18
52
|
export const defaultToolDeps = {
|
|
19
|
-
commandSucceeds: (command, args, cwd) =>
|
|
53
|
+
commandSucceeds: (command, args, cwd) => commandSucceedsInProject(cwd, command, args),
|
|
20
54
|
};
|
|
21
55
|
// How to invoke `runner` in this project, or null when nothing here can.
|
|
22
56
|
//
|
|
@@ -78,8 +112,9 @@ function locatePytest(projectRoot, deps) {
|
|
|
78
112
|
// behind. Trusting the file made provisioning report success and the boot
|
|
79
113
|
// check then say "No module named pytest" about an environment we had just
|
|
80
114
|
// built. Found on a Flask project, 2026-08-12.
|
|
81
|
-
const venvPython =
|
|
82
|
-
if (executable(
|
|
115
|
+
const venvPython = `${SIDECAR_DIR}/.venv/bin/python`;
|
|
116
|
+
if (executable(commandFileOnHost(projectRoot, venvPython)) &&
|
|
117
|
+
deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
|
|
83
118
|
return { command: venvPython, args: ['-m', 'pytest'], source: 'sidecar' };
|
|
84
119
|
}
|
|
85
120
|
for (const python of ['python3', 'python']) {
|
|
@@ -94,11 +129,11 @@ function locatePytest(projectRoot, deps) {
|
|
|
94
129
|
// checks that must not install anything. The vitest runner keeps `npx` as its
|
|
95
130
|
// own last resort, which is the behaviour it has always had.
|
|
96
131
|
function locateVitest(projectRoot) {
|
|
97
|
-
const sidecar =
|
|
98
|
-
if (executable(sidecar))
|
|
132
|
+
const sidecar = `${SIDECAR_DIR}/node_modules/.bin/vitest`;
|
|
133
|
+
if (executable(commandFileOnHost(projectRoot, sidecar)))
|
|
99
134
|
return { command: sidecar, args: [], source: 'sidecar' };
|
|
100
|
-
const project =
|
|
101
|
-
if (executable(project))
|
|
135
|
+
const project = 'node_modules/.bin/vitest';
|
|
136
|
+
if (executable(commandFileOnHost(projectRoot, project)))
|
|
102
137
|
return { command: project, args: [], source: 'project' };
|
|
103
138
|
return null;
|
|
104
139
|
}
|
|
@@ -115,8 +150,11 @@ function locateRspec(projectRoot) {
|
|
|
115
150
|
source: 'sidecar',
|
|
116
151
|
};
|
|
117
152
|
}
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
// Relative, and the slash is not decoration: `spawn` resolves a command
|
|
154
|
+
// against the working directory only when it has one, and a bare `rspec` would
|
|
155
|
+
// go looking on PATH — a different command altogether.
|
|
156
|
+
const binstub = 'bin/rspec';
|
|
157
|
+
if (executable(commandFileOnHost(projectRoot, binstub)))
|
|
120
158
|
return { command: binstub, args: [], source: 'project' };
|
|
121
159
|
return { command: 'bundle', args: ['exec', 'rspec'], source: 'project' };
|
|
122
160
|
}
|
package/dist/runner/types.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
// Read a report file back verbatim. A missing or unreadable file is a clean
|
|
3
3
|
// empty string, not a throw: the caller reports a structured suite error.
|
|
4
4
|
export function readReport(path) {
|
|
@@ -9,3 +9,47 @@ export function readReport(path) {
|
|
|
9
9
|
return '';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
// Clear the report before a run, and hand back whatever survived the attempt
|
|
13
|
+
// (spec 36, criterion 9).
|
|
14
|
+
//
|
|
15
|
+
// The report is written to a fixed path with no run marker on it, and — once the
|
|
16
|
+
// run happens in a container — into a folder both sides share. A run we gave up
|
|
17
|
+
// on can leave a process alive inside that container, and that process finishes
|
|
18
|
+
// and writes the report after we stopped waiting. The next run would then read a
|
|
19
|
+
// file belonging to a run nobody watched, and report a green result nobody
|
|
20
|
+
// earned. Having no result at all is the better of the two.
|
|
21
|
+
//
|
|
22
|
+
// What comes back is the modification time of a file that would not delete —
|
|
23
|
+
// a read-only mount, a permission we do not have — so the read below can tell
|
|
24
|
+
// "the same file, still there" from "a new one the run just wrote". Nothing is
|
|
25
|
+
// compared against this machine's clock: both stamps come from whoever wrote the
|
|
26
|
+
// file, so a container whose clock differs from the host's cannot make a good
|
|
27
|
+
// report look stale.
|
|
28
|
+
export function clearReport(path) {
|
|
29
|
+
try {
|
|
30
|
+
rmSync(path, { force: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Could not remove it. That is exactly the case the stamp below covers.
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return statSync(path).mtimeMs;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// The report of *this* run, or nothing. `survivor` is what `clearReport`
|
|
43
|
+
// returned before the run started.
|
|
44
|
+
export function readFreshReport(path, survivor) {
|
|
45
|
+
if (survivor !== null) {
|
|
46
|
+
try {
|
|
47
|
+
if (statSync(path).mtimeMs === survivor)
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return readReport(path);
|
|
55
|
+
}
|