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.
@@ -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
+ }
@@ -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 };
@@ -1,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
- import { defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
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) => runProcess(command, args, {
17
- cwd: options.cwd,
22
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, {
18
23
  timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS,
19
- env: { ...process.env, ...options.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 = sidecarPath(projectRoot, '.venv');
97
- const venvPython = join(venvDir, 'bin', 'python');
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 = join(venvDir, 'bin', 'python');
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 ${relativeVenv(projectRoot, venvDir)} did not finish on any Python ` +
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 ${relativeVenv(projectRoot, venvDir)} — ${where}. ` +
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 localBundle = join(projectRoot, 'bin', 'bundle');
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
- const gemfileRel = '.unitbob/behavioral/Gemfile';
355
- const env = { BUNDLE_GEMFILE: gemfileRel };
356
- // Try project local bin/bundle, then bundle
357
- const localBundle = join(projectRoot, 'bin', 'bundle');
358
- const cmd = existsSync(localBundle) ? localBundle : 'bundle';
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
- const venvDir = join(behavioralDir, '.venv');
375
- const venvPython = join(venvDir, 'bin', 'python');
376
- const venvPytest = join(venvDir, 'bin', 'pytest');
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 ${relativeVenv(projectRoot, venvDir)}.`,
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 ${relativeVenv(projectRoot, venvDir)}.`,
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({
@@ -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 { readReport } from "./types.js";
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 42, §6.5) — a branch is
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 result = await runProcess(command, args, {
40
- cwd: projectRoot,
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: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
43
+ env: { ...located?.env, UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
43
44
  });
44
45
  return {
45
- ...result,
46
- command,
47
- args,
46
+ ...run,
48
47
  resultPath: PYTEST_RESULT_FILE,
49
- report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
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
- out = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
59
- if out:
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
  `;
@@ -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 { readReport } from "./types.js";
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 42, §6.5). Named one by one rather than as a
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 { result, command, args } = await invokeRspec(projectRoot, [
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
- ...result,
42
- command,
43
- args,
43
+ ...run,
44
44
  resultPath: RSPEC_RESULT_FILE,
45
- report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
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
- const result = await runProcess(command, args, {
58
- cwd: projectRoot,
57
+ return runInProject(projectRoot, command, args, {
59
58
  timeoutMs: RSPEC_TIMEOUT_MS,
60
- env: { ...process.env, ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
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
  }
@@ -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) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
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 = sidecarPath(projectRoot, '.venv', 'bin', 'python');
82
- if (executable(venvPython) && deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
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 = sidecarPath(projectRoot, 'node_modules', '.bin', 'vitest');
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 = join(projectRoot, 'node_modules', '.bin', 'vitest');
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
- const binstub = join(projectRoot, 'bin', 'rspec');
119
- if (executable(binstub))
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
  }
@@ -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
+ }