unitbob 0.2.0 → 0.2.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.
- package/dist/runner/bdd.js +23 -5
- package/dist/runner/provision.js +116 -0
- package/dist/verbs/suitePrepare.js +21 -0
- 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,3 +1,5 @@
|
|
|
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";
|
|
@@ -16,6 +18,7 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
16
18
|
getRecipe: (name) => wire.getRecipe(name),
|
|
17
19
|
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
18
20
|
precheck: anyStackPrecheck,
|
|
21
|
+
ensureRunner: deps?.ensureRunner ?? (async () => ({ status: 'provisioned' })),
|
|
19
22
|
stdout: process.stdout,
|
|
20
23
|
...deps,
|
|
21
24
|
};
|
|
@@ -24,6 +27,17 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
24
27
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
25
28
|
materializeHelper(config.projectRoot);
|
|
26
29
|
const packets = await actual.getSuitePacketsBatch();
|
|
30
|
+
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight
|
|
31
|
+
for (const packet of packets) {
|
|
32
|
+
const runner = packet.runner ?? (packet.suite_kind === 'behavioral' ? inferBddRunner(config.projectRoot) : undefined);
|
|
33
|
+
if (runner && (packet.suite_kind === 'behavioral' || ['cucumber', 'cucumber-js', 'pytest-bdd'].includes(runner))) {
|
|
34
|
+
const prov = await actual.ensureRunner(config.projectRoot, runner);
|
|
35
|
+
if (prov.status === 'fixable') {
|
|
36
|
+
const checklist = prov.checklist ? `\nSteps to fix:\n- ${prov.checklist.join('\n- ')}` : '';
|
|
37
|
+
throw new Error(`Behavioral runner provision incomplete for "${runner}": ${prov.message ?? ''}${checklist}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
27
41
|
const branches = await Promise.all(packets.map(async (packet) => ({
|
|
28
42
|
suite_kind: packet.suite_kind,
|
|
29
43
|
source_digest: packet.source_digest,
|
|
@@ -38,3 +52,10 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
38
52
|
`write your answer to ${request.output_path} as a branches array, run each locally to green, ` +
|
|
39
53
|
'then run `unitbob put-suite-build`.\n');
|
|
40
54
|
}
|
|
55
|
+
function inferBddRunner(projectRoot) {
|
|
56
|
+
if (existsSync(join(projectRoot, 'package.json')))
|
|
57
|
+
return 'cucumber-js';
|
|
58
|
+
if (['pyproject.toml', 'requirements.txt', 'Pipfile'].some((f) => existsSync(join(projectRoot, f))))
|
|
59
|
+
return 'pytest-bdd';
|
|
60
|
+
return 'cucumber';
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|