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.
@@ -1,12 +1,23 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { executable, runProcess } from "../proc.js";
4
- import { readReport } from "./types.js";
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, BEHAVIORAL_ROOT, 'Gemfile');
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 env = {
122
- ...process.env,
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(result, command, args, projectRoot, CUCUMBER_REPORT);
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 sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
143
- if (!executable(sidecarBin)) {
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 result = await runProcess(command, args, {
155
- cwd: projectRoot,
162
+ const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
163
+ const run = await runInProject(projectRoot, command, args, {
156
164
  timeoutMs: BDD_TIMEOUT_MS,
157
- env: { ...process.env, NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
165
+ env: { NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
158
166
  });
159
- return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
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', projectRoot]
173
- : ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot];
174
- const result = await runProcess(command, args, {
175
- cwd: projectRoot,
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
- ...process.env,
179
- UNITBOB_REPO_ROOT: projectRoot,
180
- UNITBOB_PYTEST_BDD_REPORT: join(projectRoot, PYTEST_BDD_REPORT),
181
- PYTHONPATH: [join(projectRoot, BEHAVIORAL_ROOT), process.env.PYTHONPATH ?? ''].filter(Boolean).join(':'),
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(result, command, args, projectRoot, PYTEST_BDD_REPORT);
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(result, command, args, projectRoot, reportRel) {
208
+ function finalize(run, projectRoot, reportRel, survivor) {
192
209
  return {
193
- ...result,
194
- command,
195
- args,
210
+ ...run,
196
211
  resultPath: reportRel,
197
- report: readReport(join(projectRoot, reportRel)),
212
+ report: readFreshReport(join(projectRoot, reportRel), survivor),
198
213
  };
199
214
  }
200
215
  async function pickPython(projectRoot) {
201
- const sidecarVenvPytest = join(projectRoot, BEHAVIORAL_ROOT, '.venv', 'bin', 'pytest');
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');
@@ -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, runProcess } from "../proc.js";
4
- import { locateRunner } from "./toolchain.js";
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) => runProcess(command, args, {
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 = join(projectRoot, GUARDRAILS_DIR, HELPER_FILE);
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 localBundle = join(projectRoot, 'bin', 'bundle');
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
- return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
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: { ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
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 rails = join(projectRoot, 'bin', 'rails');
378
- const useBinstub = executable(rails);
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
+ }