unitbob 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
@@ -22,51 +33,112 @@ export const BDD_RUN_ARTIFACTS = [
22
33
  PYTEST_BDD_PLUGIN_NAME,
23
34
  PYTEST_INI_NAME,
24
35
  ];
36
+ // One name for the directory every strategy points its loader at, so the
37
+ // descriptors below and the commands below them cannot come to mean different
38
+ // directories.
39
+ const STEP_DEFINITIONS = 'step_definitions';
25
40
  const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
26
41
  const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
27
42
  const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
28
43
  const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
29
44
  const PYTEST_INI = '[pytest]\naddopts =\n';
45
+ // Load order is a fact about both Cucumbers and about neither pytest — pytest
46
+ // picks `conftest.py` up itself, so there is no trap to work around there.
47
+ const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file is not special to the runner — `account_access` ' +
48
+ 'loads before `shared`. Open each capability file with an explicit require of the shared one rather ' +
49
+ 'than trusting the alphabet.';
30
50
  // The connector-owned BDD strategy table (spec 32): the `runner` enum names one
31
51
  // of these; the connector never executes a host-provided command string. Each
32
52
  // strategy runs the whole behavioral bundle and returns the raw machine-readable
33
53
  // report verbatim — the connector does no marker join and no aggregation.
54
+ //
55
+ // A strategy is its command *and* its loading rule. They are one entry so that a
56
+ // fourth runner cannot be added with only half of itself stated.
57
+ const BDD_STRATEGIES = {
58
+ cucumber: {
59
+ run: (projectRoot) => runCucumberRuby(projectRoot),
60
+ loading: {
61
+ step_files: '*.rb',
62
+ requirements: [
63
+ 'The connector points `--require` at `step_definitions/`, so every `.rb` file there is loaded. ' +
64
+ 'That explicit `--require` also switches off Cucumber\'s automatic loading of `features/support/`: ' +
65
+ 'a World or helper parked there is never evaluated, and every step then fails on a bare object.',
66
+ CUCUMBER_LOAD_ORDER,
67
+ ],
68
+ },
69
+ },
70
+ 'cucumber-js': {
71
+ run: (projectRoot) => runCucumberJs(projectRoot),
72
+ loading: {
73
+ step_files: '*.js',
74
+ requirements: [
75
+ 'Keep `step_definitions/` to CommonJS JavaScript and nothing else. The connector passes the whole ' +
76
+ 'directory to `--require`, and cucumber-js `require()`s every file it matches whatever the ' +
77
+ 'extension — a stray `.ts`, `.json` or `.md` left there is executed as JavaScript and aborts the ' +
78
+ 'entire run with a parse error, before a single scenario.',
79
+ 'The connector registers no TypeScript loader, so a `.ts` file cannot compile itself. If you want ' +
80
+ 'one, register the compiler from the file that sorts first — and remember the file registering it ' +
81
+ 'is itself loaded as plain JavaScript.',
82
+ CUCUMBER_LOAD_ORDER,
83
+ ],
84
+ },
85
+ },
86
+ 'pytest-bdd': {
87
+ run: (projectRoot, mainPath) => runPytestBdd(projectRoot, mainPath),
88
+ loading: {
89
+ step_files: 'test_*.py',
90
+ requirements: [
91
+ 'pytest collects `step_definitions/` under its own default, which is `test_*.py` and `*_test.py`. ' +
92
+ 'The connector writes no `python_files` setting and will not: a file named outside those two — ' +
93
+ '`<capability>_steps.py`, say — is simply not collected. No error, no scenarios, a green run ' +
94
+ 'over nothing.',
95
+ '`conftest.py` is picked up by pytest itself whatever else sits beside it, so shared fixtures ' +
96
+ 'belong there and there is no load-order trap to work around.',
97
+ ],
98
+ },
99
+ },
100
+ };
34
101
  export function runBddSuite(projectRoot, runner, mainPath) {
35
- switch (runner) {
36
- case 'cucumber':
37
- return runCucumberRuby(projectRoot);
38
- case 'cucumber-js':
39
- return runCucumberJs(projectRoot);
40
- case 'pytest-bdd':
41
- return runPytestBdd(projectRoot, mainPath);
42
- default:
43
- return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
102
+ const strategy = strategyFor(runner);
103
+ if (!strategy) {
104
+ return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
44
105
  }
106
+ return strategy.run(projectRoot, mainPath);
107
+ }
108
+ // How this runner loads step files, for whoever has to write one. Null for a
109
+ // runner this connector does not run, which is the same answer `runBddSuite`
110
+ // gives it.
111
+ export function bddStepLoading(runner) {
112
+ return strategyFor(runner)?.loading ?? null;
113
+ }
114
+ // `Object.hasOwn` rather than a bare index: the runner name arrives over the
115
+ // wire, and `constructor` would otherwise come back as a truthy strategy with no
116
+ // `run` on it.
117
+ function strategyFor(runner) {
118
+ return Object.hasOwn(BDD_STRATEGIES, runner) ? BDD_STRATEGIES[runner] : null;
45
119
  }
46
120
  // Ruby: `cucumber` with the built-in message formatter. The features and step
47
121
  // definitions both live under the behavioral root; --require points at the step
48
122
  // definitions so only the Unitbob bundle loads.
49
123
  async function runCucumberRuby(projectRoot) {
50
124
  const features = join(BEHAVIORAL_ROOT, 'features');
51
- const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
52
- const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
125
+ const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
126
+ const sidecarGemfile = join(projectRoot, BEHAVIORAL_GEMFILE);
53
127
  if (!existsSync(sidecarGemfile)) {
54
128
  throw missingRunner('Cucumber');
55
129
  }
56
130
  const command = 'bundle';
57
131
  const args = ['exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
58
- const env = {
59
- ...process.env,
60
- RAILS_ENV: 'test',
61
- UNITBOB_REPO_ROOT: projectRoot,
62
- };
63
- env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
64
- const result = await runProcess(command, args, {
65
- cwd: projectRoot,
132
+ const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
133
+ const run = await runInProject(projectRoot, command, args, {
66
134
  timeoutMs: BDD_TIMEOUT_MS,
67
- env,
135
+ env: {
136
+ RAILS_ENV: 'test',
137
+ UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
138
+ BUNDLE_GEMFILE: BEHAVIORAL_GEMFILE,
139
+ },
68
140
  });
69
- return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
141
+ return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
70
142
  }
71
143
  function missingRunner(name) {
72
144
  return new Error(`Behavioral runner missing (${name}). Run suite-prepare to provision it under ${BEHAVIORAL_ROOT}/, then run the checks again.`);
@@ -75,12 +147,11 @@ function missingRunner(name) {
75
147
  // to a file.
76
148
  async function runCucumberJs(projectRoot) {
77
149
  const features = join(BEHAVIORAL_ROOT, 'features');
78
- const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
79
- const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
80
- if (!executable(sidecarBin)) {
150
+ const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
151
+ const command = `${BEHAVIORAL_ROOT}/node_modules/.bin/cucumber-js`;
152
+ if (!executable(commandFileOnHost(projectRoot, command))) {
81
153
  throw missingRunner('Cucumber JS');
82
154
  }
83
- const command = sidecarBin;
84
155
  const args = [
85
156
  features,
86
157
  '--require',
@@ -88,12 +159,12 @@ async function runCucumberJs(projectRoot) {
88
159
  '--format',
89
160
  `message:${CUCUMBER_REPORT}`,
90
161
  ];
91
- const result = await runProcess(command, args, {
92
- cwd: projectRoot,
162
+ const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
163
+ const run = await runInProject(projectRoot, command, args, {
93
164
  timeoutMs: BDD_TIMEOUT_MS,
94
- env: { ...process.env, NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
165
+ env: { NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
95
166
  });
96
- return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
167
+ return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
97
168
  }
98
169
  // Python: pytest driving pytest-bdd, with the connector's reporter plugin. The
99
170
  // plugin writes the JSON report; `-c` isolates the run from the project's own
@@ -103,40 +174,47 @@ async function runPytestBdd(projectRoot, mainPath) {
103
174
  writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
104
175
  writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
105
176
  const command = await pickPython(projectRoot);
106
- const stepsDir = join(BEHAVIORAL_ROOT, 'step_definitions');
177
+ const stepsDir = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
107
178
  const isVenvPytest = command.endsWith('/pytest');
179
+ // `--rootdir .`, not the absolute root: the working directory is the project
180
+ // root in every place, and an absolute host path would name a directory that
181
+ // does not exist wherever the run actually happens.
108
182
  const args = isVenvPytest
109
- ? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot]
110
- : ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot];
111
- const result = await runProcess(command, args, {
112
- 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, {
113
187
  timeoutMs: BDD_TIMEOUT_MS,
114
188
  env: {
115
- ...process.env,
116
- UNITBOB_REPO_ROOT: projectRoot,
117
- UNITBOB_PYTEST_BDD_REPORT: join(projectRoot, PYTEST_BDD_REPORT),
118
- 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,
119
199
  },
120
200
  });
121
- return finalize(result, command, args, projectRoot, PYTEST_BDD_REPORT);
201
+ return finalize(run, projectRoot, PYTEST_BDD_REPORT, survivor);
122
202
  // mainPath is accepted for symmetry with the structural runners; pytest-bdd
123
203
  // discovers scenarios from the step-definition modules, not the .feature path.
124
204
  }
125
205
  function pluginModule() {
126
206
  return 'unitbob_pytest_bdd_plugin';
127
207
  }
128
- function finalize(result, command, args, projectRoot, reportRel) {
208
+ function finalize(run, projectRoot, reportRel, survivor) {
129
209
  return {
130
- ...result,
131
- command,
132
- args,
210
+ ...run,
133
211
  resultPath: reportRel,
134
- report: readReport(join(projectRoot, reportRel)),
212
+ report: readFreshReport(join(projectRoot, reportRel), survivor),
135
213
  };
136
214
  }
137
215
  async function pickPython(projectRoot) {
138
- const sidecarVenvPytest = join(projectRoot, BEHAVIORAL_ROOT, '.venv', 'bin', 'pytest');
139
- if (executable(sidecarVenvPytest)) {
216
+ const sidecarVenvPytest = `${BEHAVIORAL_ROOT}/.venv/bin/pytest`;
217
+ if (executable(commandFileOnHost(projectRoot, sidecarVenvPytest))) {
140
218
  return sidecarVenvPytest;
141
219
  }
142
220
  throw missingRunner('pytest-bdd');
@@ -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
+ }