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.
@@ -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: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
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 command = 'npx';
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
- 'cucumber-js',
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 args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p',
79
- pluginModule(), stepsDir, '--rootdir', projectRoot];
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.0",
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": {