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