unitbob 0.2.0 → 0.2.2
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/dist/runner/bdd.js +23 -5
- package/dist/runner/provision.js +116 -0
- package/dist/verbs/suitePrepare.js +41 -2
- package/package.json +1 -1
package/dist/runner/bdd.js
CHANGED
|
@@ -35,13 +35,23 @@ async function runCucumberRuby(projectRoot) {
|
|
|
35
35
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
36
36
|
const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
|
|
37
37
|
const localBin = join(projectRoot, 'bin', 'cucumber');
|
|
38
|
+
const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
|
|
39
|
+
const hasSidecarGemfile = existsSync(sidecarGemfile);
|
|
38
40
|
const command = executable(localBin) ? localBin : 'bundle';
|
|
39
41
|
const base = executable(localBin) ? [] : ['exec', 'cucumber'];
|
|
40
42
|
const args = [...base, features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
|
|
43
|
+
const env = {
|
|
44
|
+
...process.env,
|
|
45
|
+
RAILS_ENV: 'test',
|
|
46
|
+
UNITBOB_REPO_ROOT: projectRoot,
|
|
47
|
+
};
|
|
48
|
+
if (hasSidecarGemfile) {
|
|
49
|
+
env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
|
|
50
|
+
}
|
|
41
51
|
const result = await runProcess(command, args, {
|
|
42
52
|
cwd: projectRoot,
|
|
43
53
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
44
|
-
env
|
|
54
|
+
env,
|
|
45
55
|
});
|
|
46
56
|
return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
|
|
47
57
|
}
|
|
@@ -50,9 +60,11 @@ async function runCucumberRuby(projectRoot) {
|
|
|
50
60
|
async function runCucumberJs(projectRoot) {
|
|
51
61
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
52
62
|
const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
|
|
53
|
-
const
|
|
63
|
+
const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
|
|
64
|
+
const command = executable(sidecarBin) ? sidecarBin : 'npx';
|
|
65
|
+
const baseArgs = executable(sidecarBin) ? [] : ['cucumber-js'];
|
|
54
66
|
const args = [
|
|
55
|
-
|
|
67
|
+
...baseArgs,
|
|
56
68
|
features,
|
|
57
69
|
'--require',
|
|
58
70
|
steps,
|
|
@@ -75,8 +87,10 @@ async function runPytestBdd(projectRoot, mainPath) {
|
|
|
75
87
|
writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
|
|
76
88
|
const command = await pickPython(projectRoot);
|
|
77
89
|
const stepsDir = join(BEHAVIORAL_ROOT, 'step_definitions');
|
|
78
|
-
const
|
|
79
|
-
|
|
90
|
+
const isVenvPytest = command.endsWith('/pytest');
|
|
91
|
+
const args = isVenvPytest
|
|
92
|
+
? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot]
|
|
93
|
+
: ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot];
|
|
80
94
|
const result = await runProcess(command, args, {
|
|
81
95
|
cwd: projectRoot,
|
|
82
96
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
@@ -104,6 +118,10 @@ function finalize(result, command, args, projectRoot, reportRel) {
|
|
|
104
118
|
};
|
|
105
119
|
}
|
|
106
120
|
async function pickPython(projectRoot) {
|
|
121
|
+
const sidecarVenvPytest = join(projectRoot, BEHAVIORAL_ROOT, '.venv', 'bin', 'pytest');
|
|
122
|
+
if (executable(sidecarVenvPytest)) {
|
|
123
|
+
return sidecarVenvPytest;
|
|
124
|
+
}
|
|
107
125
|
const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
|
|
108
126
|
cwd: projectRoot,
|
|
109
127
|
timeoutMs: 10_000,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runProcess } from "../proc.js";
|
|
4
|
+
const defaultDeps = {
|
|
5
|
+
runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs: 120_000, env: { ...process.env, ...options.env } }),
|
|
6
|
+
};
|
|
7
|
+
export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
|
|
8
|
+
const behavioralDir = join(projectRoot, '.unitbob', 'behavioral');
|
|
9
|
+
mkdirSync(behavioralDir, { recursive: true });
|
|
10
|
+
switch (runner) {
|
|
11
|
+
case 'cucumber':
|
|
12
|
+
return provisionRuby(projectRoot, behavioralDir, deps);
|
|
13
|
+
case 'cucumber-js':
|
|
14
|
+
return provisionJs(projectRoot, behavioralDir, deps);
|
|
15
|
+
case 'pytest-bdd':
|
|
16
|
+
return provisionPython(projectRoot, behavioralDir, deps);
|
|
17
|
+
default:
|
|
18
|
+
return { status: 'fixable', message: `Unsupported BDD runner "${runner}".` };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
22
|
+
const sidecarGemfile = join(behavioralDir, 'Gemfile');
|
|
23
|
+
const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
|
|
24
|
+
'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
|
|
25
|
+
'gem "cucumber", "~> 9.0", require: false\n';
|
|
26
|
+
if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
|
|
27
|
+
writeFileSync(sidecarGemfile, sidecarContent);
|
|
28
|
+
}
|
|
29
|
+
const gemfileRel = '.unitbob/behavioral/Gemfile';
|
|
30
|
+
const env = { BUNDLE_GEMFILE: gemfileRel };
|
|
31
|
+
// Try project local bin/bundle, then bundle
|
|
32
|
+
const localBundle = join(projectRoot, 'bin', 'bundle');
|
|
33
|
+
const cmd = existsSync(localBundle) ? localBundle : 'bundle';
|
|
34
|
+
const result = await deps.runCmd(cmd, ['install'], { cwd: projectRoot, env }).catch((err) => ({
|
|
35
|
+
code: 1,
|
|
36
|
+
stdout: '',
|
|
37
|
+
stderr: String(err),
|
|
38
|
+
}));
|
|
39
|
+
if (result.code === 0) {
|
|
40
|
+
return { status: 'provisioned' };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
status: 'fixable',
|
|
44
|
+
message: 'Bundler failed to provision Cucumber sidecar gem.',
|
|
45
|
+
checklist: ['Ensure bundler is installed (`gem install bundler`) and run `bundle install` manually inside `.unitbob/behavioral/`.'],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function provisionPython(projectRoot, behavioralDir, deps) {
|
|
49
|
+
const venvDir = join(behavioralDir, '.venv');
|
|
50
|
+
const venvPip = join(venvDir, 'bin', 'pip');
|
|
51
|
+
const venvPytest = join(venvDir, 'bin', 'pytest');
|
|
52
|
+
if (existsSync(venvPytest)) {
|
|
53
|
+
return { status: 'provisioned' };
|
|
54
|
+
}
|
|
55
|
+
// Ladder: uv -> python3 -m venv --system-site-packages
|
|
56
|
+
const uvResult = await deps.runCmd('uv', ['venv', venvDir, '--system-site-packages'], { cwd: projectRoot }).catch(() => ({ code: 1 }));
|
|
57
|
+
let venvCreated = uvResult.code === 0;
|
|
58
|
+
if (!venvCreated) {
|
|
59
|
+
const venvResult = await deps
|
|
60
|
+
.runCmd('python3', ['-m', 'venv', '--system-site-packages', venvDir], { cwd: projectRoot })
|
|
61
|
+
.catch(() => ({ code: 1 }));
|
|
62
|
+
venvCreated = venvResult.code === 0;
|
|
63
|
+
}
|
|
64
|
+
if (!venvCreated) {
|
|
65
|
+
return {
|
|
66
|
+
status: 'fixable',
|
|
67
|
+
message: 'Failed to create virtual environment under .unitbob/behavioral/.venv.',
|
|
68
|
+
checklist: ['Install python3-venv or uv: `python3 -m venv --help` or `pip install uv`.'],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Install pytest-bdd into the sidecar venv
|
|
72
|
+
const pipResult = await deps.runCmd(venvPip, ['install', 'pytest-bdd'], { cwd: projectRoot }).catch(() => ({ code: 1 }));
|
|
73
|
+
if (pipResult.code === 0 || existsSync(venvPytest)) {
|
|
74
|
+
return { status: 'provisioned' };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
status: 'fixable',
|
|
78
|
+
message: 'Failed to install pytest-bdd into .unitbob/behavioral/.venv.',
|
|
79
|
+
checklist: [`Run \`${venvPip} install pytest-bdd\` manually to provision the runner.`],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function provisionJs(projectRoot, behavioralDir, deps) {
|
|
83
|
+
const sidecarPkg = join(behavioralDir, 'package.json');
|
|
84
|
+
const sidecarContent = JSON.stringify({
|
|
85
|
+
name: 'unitbob-behavioral-sidecar',
|
|
86
|
+
private: true,
|
|
87
|
+
devDependencies: {
|
|
88
|
+
'@cucumber/cucumber': '^10.0.0',
|
|
89
|
+
'ts-node': '^10.9.0',
|
|
90
|
+
},
|
|
91
|
+
}, null, 2) + '\n';
|
|
92
|
+
if (!existsSync(sidecarPkg) || readFileSync(sidecarPkg, 'utf8') !== sidecarContent) {
|
|
93
|
+
writeFileSync(sidecarPkg, sidecarContent);
|
|
94
|
+
}
|
|
95
|
+
const cucumberBin = join(behavioralDir, 'node_modules', '.bin', 'cucumber-js');
|
|
96
|
+
if (existsSync(cucumberBin)) {
|
|
97
|
+
return { status: 'provisioned' };
|
|
98
|
+
}
|
|
99
|
+
// Fallback ladder: npm -> pnpm -> yarn
|
|
100
|
+
const managers = [
|
|
101
|
+
{ cmd: 'npm', args: ['install', '--prefix', '.unitbob/behavioral'] },
|
|
102
|
+
{ cmd: 'pnpm', args: ['install', '--prefix', '.unitbob/behavioral'] },
|
|
103
|
+
{ cmd: 'yarn', args: ['install', '--cwd', '.unitbob/behavioral'] },
|
|
104
|
+
];
|
|
105
|
+
for (const mgr of managers) {
|
|
106
|
+
const res = await deps.runCmd(mgr.cmd, mgr.args, { cwd: projectRoot }).catch(() => ({ code: 1 }));
|
|
107
|
+
if (res.code === 0 || existsSync(cucumberBin)) {
|
|
108
|
+
return { status: 'provisioned' };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
status: 'fixable',
|
|
113
|
+
message: 'Failed to install @cucumber/cucumber sidecar dependency.',
|
|
114
|
+
checklist: ['Install dependencies manually: `npm install --prefix .unitbob/behavioral`.'],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { materializeHelper } from "../files/guardrails.js";
|
|
2
4
|
import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
3
5
|
import { anyStackPrecheck } from "../runner/precheck.js";
|
|
6
|
+
import { ensureRunner } from "../runner/provision.js";
|
|
4
7
|
import { Wire } from "../wire.js";
|
|
5
8
|
// Confirm at least one supported stack is present, materialize the Ruby boot
|
|
6
9
|
// helper a generated RSpec suite would require, then fetch both peer assignments
|
|
@@ -16,6 +19,7 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
16
19
|
getRecipe: (name) => wire.getRecipe(name),
|
|
17
20
|
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
18
21
|
precheck: anyStackPrecheck,
|
|
22
|
+
ensureRunner: deps?.ensureRunner ?? ensureRunner,
|
|
19
23
|
stdout: process.stdout,
|
|
20
24
|
...deps,
|
|
21
25
|
};
|
|
@@ -24,7 +28,27 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
24
28
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
25
29
|
materializeHelper(config.projectRoot);
|
|
26
30
|
const packets = await actual.getSuitePacketsBatch();
|
|
27
|
-
|
|
31
|
+
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
|
|
32
|
+
// A `fixable` outcome (no package manager available to install the runner) is an infrastructure
|
|
33
|
+
// blocker the vibecoder clears with one command. Per the spec it must NOT surface as a
|
|
34
|
+
// build_error dead-end, must NOT abort the build, and must NOT block the structural peer. So we
|
|
35
|
+
// drop only the unprovisioned behavioral branch from this run, keep everything else buildable,
|
|
36
|
+
// and print a fixable checklist after the request is written.
|
|
37
|
+
const fixableNotices = [];
|
|
38
|
+
const buildable = [];
|
|
39
|
+
for (const packet of packets) {
|
|
40
|
+
const runner = packet.runner ?? (packet.suite_kind === 'behavioral' ? inferBddRunner(config.projectRoot) : undefined);
|
|
41
|
+
if (runner && (packet.suite_kind === 'behavioral' || ['cucumber', 'cucumber-js', 'pytest-bdd'].includes(runner))) {
|
|
42
|
+
const prov = await actual.ensureRunner(config.projectRoot, runner);
|
|
43
|
+
if (prov.status === 'fixable') {
|
|
44
|
+
const steps = prov.checklist?.length ? `\n - ${prov.checklist.join('\n - ')}` : '';
|
|
45
|
+
fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
buildable.push(packet);
|
|
50
|
+
}
|
|
51
|
+
const branches = await Promise.all(buildable.map(async (packet) => ({
|
|
28
52
|
suite_kind: packet.suite_kind,
|
|
29
53
|
source_digest: packet.source_digest,
|
|
30
54
|
path_root: packet.path_root,
|
|
@@ -34,7 +58,22 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
34
58
|
const request = writeSuiteBuildRequest(config.projectRoot, branches);
|
|
35
59
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
36
60
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
37
|
-
actual.stdout.write(`Next: build both peer suites (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
61
|
+
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
38
62
|
`write your answer to ${request.output_path} as a branches array, run each locally to green, ` +
|
|
39
63
|
'then run `unitbob put-suite-build`.\n');
|
|
64
|
+
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
65
|
+
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
66
|
+
if (fixableNotices.length > 0) {
|
|
67
|
+
actual.stdout.write('\nBehavioral suite skipped this run — its BDD runner is not installed yet. ' +
|
|
68
|
+
'This is a fixable setup step, not a build failure, and it does not affect the structural suite:\n' +
|
|
69
|
+
fixableNotices.join('\n') +
|
|
70
|
+
'\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function inferBddRunner(projectRoot) {
|
|
74
|
+
if (existsSync(join(projectRoot, 'package.json')))
|
|
75
|
+
return 'cucumber-js';
|
|
76
|
+
if (['pyproject.toml', 'requirements.txt', 'Pipfile'].some((f) => existsSync(join(projectRoot, f))))
|
|
77
|
+
return 'pytest-bdd';
|
|
78
|
+
return 'cucumber';
|
|
40
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|