unitbob 0.1.11 → 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/cli.js +7 -1
- package/dist/files/artifactPath.js +19 -0
- package/dist/files/behavioral.js +29 -0
- package/dist/files/guardrails.js +6 -4
- package/dist/files/suiteBuild.js +65 -57
- package/dist/runner/bdd.js +138 -0
- package/dist/runner/boundReport.js +107 -0
- package/dist/runner/precheck.js +60 -1
- package/dist/runner/provision.js +116 -0
- package/dist/runner/pytestBddPlugin.js +62 -0
- package/dist/verbs/contractPrompt.js +25 -0
- package/dist/verbs/putSuiteBuild.js +42 -28
- package/dist/verbs/run.js +71 -144
- package/dist/verbs/suitePrepare.js +46 -20
- package/dist/wire.js +58 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The connector-owned pytest-bdd reporter (spec 32). Python has no Cucumber
|
|
2
|
+
// Messages emitter, so the connector ships this small deterministic plugin. It
|
|
3
|
+
// hangs off pytest-bdd's public scenario/step hooks and writes one JSON document
|
|
4
|
+
// the Rails PytestBddJson parser reads:
|
|
5
|
+
//
|
|
6
|
+
// { "version": 1, "scenarios": [ { name, tags, status, failure, steps: [...] } ] }
|
|
7
|
+
//
|
|
8
|
+
// It only records — it never reconciles markers or aggregates capabilities. It
|
|
9
|
+
// is connector-owned and never part of the LLM-generated step definitions.
|
|
10
|
+
export const PYTEST_BDD_PLUGIN = `# Written by the unitbob connector — do not edit.
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
_UNITBOB_REPORT = {"version": 1, "scenarios": []}
|
|
15
|
+
_UNITBOB_CURRENT = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pytest_bdd_before_scenario(request, feature, scenario):
|
|
19
|
+
_UNITBOB_CURRENT[id(scenario)] = {
|
|
20
|
+
"name": scenario.name,
|
|
21
|
+
"tags": sorted(scenario.tags),
|
|
22
|
+
"status": "passed",
|
|
23
|
+
"failure": "",
|
|
24
|
+
"steps": [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _record_step(scenario, step, status):
|
|
29
|
+
entry = _UNITBOB_CURRENT.get(id(scenario))
|
|
30
|
+
if entry is None:
|
|
31
|
+
return
|
|
32
|
+
entry["steps"].append({
|
|
33
|
+
"keyword": getattr(step, "keyword", "").strip(),
|
|
34
|
+
"text": step.name,
|
|
35
|
+
"status": status,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pytest_bdd_after_step(request, feature, scenario, step, step_func):
|
|
40
|
+
_record_step(scenario, step, "passed")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception):
|
|
44
|
+
entry = _UNITBOB_CURRENT.get(id(scenario))
|
|
45
|
+
if entry is not None:
|
|
46
|
+
entry["status"] = "failed"
|
|
47
|
+
entry["failure"] = "{}: {}".format(type(exception).__name__, exception)
|
|
48
|
+
_record_step(scenario, step, "failed")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pytest_bdd_after_scenario(request, feature, scenario):
|
|
52
|
+
entry = _UNITBOB_CURRENT.pop(id(scenario), None)
|
|
53
|
+
if entry is not None:
|
|
54
|
+
_UNITBOB_REPORT["scenarios"].append(entry)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
58
|
+
out = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
|
|
59
|
+
if out:
|
|
60
|
+
with open(out, "w") as handle:
|
|
61
|
+
json.dump(_UNITBOB_REPORT, handle)
|
|
62
|
+
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Wire } from "../wire.js";
|
|
2
|
+
// Fetch the contract action brief for one red check (spec 32). One operation for
|
|
3
|
+
// both maps and both intents: the digest names the exact current version (and so
|
|
4
|
+
// its contract system), `test_id` is that kind's own id, `intent` is fix|accept.
|
|
5
|
+
// The server composes the whole prompt; the connector prints its plain-language
|
|
6
|
+
// `message` and the copy-ready `prompt`. A 422 (not current / not failing /
|
|
7
|
+
// unknown intent) surfaces via WireError; nothing is fabricated.
|
|
8
|
+
export async function contractPrompt(config, args = [], deps) {
|
|
9
|
+
const suiteDigest = (args[0] ?? '').trim();
|
|
10
|
+
const testId = (args[1] ?? '').trim();
|
|
11
|
+
const intent = (args[2] ?? 'fix').trim();
|
|
12
|
+
if (!suiteDigest || !testId) {
|
|
13
|
+
throw new Error('Usage: unitbob contract-prompt <suite_digest> <test_id> [fix|accept]');
|
|
14
|
+
}
|
|
15
|
+
if (intent !== 'fix' && intent !== 'accept') {
|
|
16
|
+
throw new Error(`intent must be "fix" or "accept" (got "${intent}").`);
|
|
17
|
+
}
|
|
18
|
+
const d = {
|
|
19
|
+
getContractPrompt: (digest, id, action) => new Wire(config).getContractPrompt(digest, id, action),
|
|
20
|
+
stdout: process.stdout,
|
|
21
|
+
...deps,
|
|
22
|
+
};
|
|
23
|
+
const packet = await d.getContractPrompt(suiteDigest, testId, intent);
|
|
24
|
+
d.stdout.write(`${packet.message}\n\n${packet.prompt}\n`);
|
|
25
|
+
}
|
|
@@ -1,35 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { validateStack } from "../runner/precheck.js";
|
|
1
|
+
import { readHostSuiteOutputs, readSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
3
2
|
import { Wire } from "../wire.js";
|
|
4
|
-
// Read the task and the host's
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// nothing is uploaded and the previous suite stands.
|
|
3
|
+
// Read the task and the host's answers, verify each branch parses and carries a
|
|
4
|
+
// safe-path artifact envelope, then upload both peer branches in one batch
|
|
5
|
+
// (spec 32). `source_digest` comes from the task — never the host's answer — so
|
|
6
|
+
// the host cannot claim a different map than each branch was given. A branch the
|
|
7
|
+
// host could not build is uploaded as a `build_error`, which never rolls back the
|
|
8
|
+
// peer branch. If a branch's answer is unparseable, nothing is uploaded.
|
|
11
9
|
export async function putSuiteBuild(config, _args = [], deps) {
|
|
12
10
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
const outputs = readHostSuiteOutputs(request.output_path, request);
|
|
12
|
+
const d = {
|
|
13
|
+
putSuiteBuilds: (items) => new Wire(config).putSuiteBuilds(items),
|
|
14
|
+
stdout: process.stdout,
|
|
17
15
|
...deps,
|
|
18
16
|
};
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
const digestFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.source_digest]));
|
|
18
|
+
const items = outputs.map((output) => {
|
|
19
|
+
const sourceDigest = digestFor.get(output.suite_kind) ?? '';
|
|
20
|
+
if (output.build_error) {
|
|
21
|
+
return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
suite_kind: output.suite_kind,
|
|
25
|
+
source_digest: sourceDigest,
|
|
26
|
+
artifacts: {
|
|
27
|
+
suite_file: output.suite_file,
|
|
28
|
+
runner_manifest: output.runner_manifest,
|
|
29
|
+
test_metadata: output.test_metadata,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
29
32
|
});
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const results = await d.putSuiteBuilds(items);
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
d.stdout.write(`${printResult(result)}\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function printResult(result) {
|
|
39
|
+
if (result.status === 'error' || result.status === 'build_error') {
|
|
40
|
+
return `${result.suite_kind}: not published — ${result.error ?? 'the host could not build this suite'}.`;
|
|
41
|
+
}
|
|
42
|
+
const tallies = result.counts
|
|
43
|
+
? Object.entries(result.counts)
|
|
44
|
+
.map(([name, value]) => `${value} ${name}`)
|
|
45
|
+
.join(', ')
|
|
46
|
+
: '';
|
|
47
|
+
const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
|
|
48
|
+
return `${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.`;
|
|
35
49
|
}
|
package/dist/verbs/run.js
CHANGED
|
@@ -1,59 +1,90 @@
|
|
|
1
1
|
import { materializeGuardrails } from "../files/guardrails.js";
|
|
2
|
+
import { materializeBehavioral } from "../files/behavioral.js";
|
|
2
3
|
import { validateStack } from "../runner/precheck.js";
|
|
3
4
|
import { runRspecSuite } from "../runner/rspec.js";
|
|
4
5
|
import { runVitestSuite } from "../runner/vitest.js";
|
|
5
6
|
import { runPytestSuite } from "../runner/pytest.js";
|
|
7
|
+
import { runBddSuite } from "../runner/bdd.js";
|
|
8
|
+
import { boundReport } from "../runner/boundReport.js";
|
|
6
9
|
import { Wire } from "../wire.js";
|
|
7
|
-
// Bound failure text for transport/storage hygiene (spec 26). This is a size
|
|
8
|
-
// limit only, not a privacy filter — application values in failure messages are
|
|
9
|
-
// accepted, not scrubbed.
|
|
10
|
-
const MAX_FAILURE_CHARS = 8000;
|
|
11
10
|
const OUTPUT_TAIL_CHARS = 2000;
|
|
12
|
-
// The check flow (spec 30): fetch the current suite blob, confirm the local
|
|
13
|
-
// project matches its runner, materialize the guardrail file, execute the
|
|
14
|
-
// connector-owned strategy named by `runner_manifest.runner`, and ship the raw
|
|
15
|
-
// machine-readable report (or a structured runner error) to Rails.
|
|
16
11
|
export async function run(config, _args, deps) {
|
|
17
12
|
const wire = new Wire(config);
|
|
18
13
|
const d = {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
materializeGuardrails,
|
|
22
|
-
|
|
14
|
+
getSuites: () => wire.getSuites(),
|
|
15
|
+
postRunsBatch: (runs) => wire.postRunsBatch(runs),
|
|
16
|
+
materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
|
|
17
|
+
suite_digest: item.suite_digest,
|
|
18
|
+
suite_file: { path: item.suite_file.path, content: item.suite_file.content },
|
|
19
|
+
runner_manifest: item.runner_manifest,
|
|
20
|
+
}),
|
|
21
|
+
materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file).mainPath,
|
|
22
|
+
runStructural: runStructuralByRunner,
|
|
23
|
+
runBehavioral: runBddSuite,
|
|
23
24
|
validateStack,
|
|
24
25
|
stdout: process.stdout,
|
|
25
26
|
...deps,
|
|
26
27
|
};
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const suites = await d.getSuites();
|
|
29
|
+
const ready = suites.filter((item) => item.status === 'ready');
|
|
30
|
+
if (ready.length === 0) {
|
|
31
|
+
d.stdout.write('No Unitbob suites exist yet. Generate them first, then run the Unitbob checks again.\n');
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
+
const runs = [];
|
|
35
|
+
for (const item of ready) {
|
|
36
|
+
runs.push(await buildRunPayload(config, d, item));
|
|
37
|
+
}
|
|
38
|
+
const { results, map_url } = await d.postRunsBatch(runs);
|
|
39
|
+
for (const result of results)
|
|
40
|
+
d.stdout.write(`${result.summary}\n`);
|
|
41
|
+
if (map_url)
|
|
42
|
+
d.stdout.write(`${map_url}\n`);
|
|
43
|
+
}
|
|
44
|
+
// One branch's run payload. A stack mismatch, a materialize failure, or a runner
|
|
45
|
+
// that produced no report all become this branch's structured suite error — the
|
|
46
|
+
// peer branch is unaffected. This connector never installs anything: a missing
|
|
47
|
+
// or broken runner surfaces here as a suite error, not an install.
|
|
48
|
+
async function buildRunPayload(config, d, item) {
|
|
49
|
+
const runner = item.runner_manifest.runner;
|
|
50
|
+
const behavioral = item.suite_kind === 'behavioral';
|
|
51
|
+
// Confirm the local stack before touching the tree, for both contract systems.
|
|
52
|
+
// A mismatch is this branch's suite error — reported and left for the peer
|
|
53
|
+
// branch to run regardless. The behavioral check confirms only the base
|
|
54
|
+
// language; a missing BDD runner still surfaces from the run itself, since
|
|
55
|
+
// check installs nothing.
|
|
34
56
|
const check = d.validateStack(config.projectRoot, runner);
|
|
35
57
|
if (!check.ok)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
return suiteError(item.suite_digest, check.message ?? `Local project does not match "${runner}".`);
|
|
59
|
+
let result;
|
|
60
|
+
try {
|
|
61
|
+
if (behavioral) {
|
|
62
|
+
const mainPath = d.materializeBehavioral(config.projectRoot, item);
|
|
63
|
+
result = await d.runBehavioral(config.projectRoot, runner, mainPath);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
d.materializeStructural(config.projectRoot, item);
|
|
67
|
+
result = await d.runStructural(config.projectRoot, runner, item.suite_file.path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return suiteError(item.suite_digest, err.message);
|
|
72
|
+
}
|
|
73
|
+
const report = boundReport(runner, result);
|
|
74
|
+
if (report === null) {
|
|
75
|
+
return {
|
|
76
|
+
suite_digest: item.suite_digest,
|
|
77
|
+
suite_error: {
|
|
78
|
+
command: [result.command, ...result.args].join(' '),
|
|
79
|
+
exit_code: result.code,
|
|
80
|
+
result_path: result.resultPath,
|
|
81
|
+
output_tail: outputTail(result),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { suite_digest: item.suite_digest, run_result: report };
|
|
52
86
|
}
|
|
53
|
-
|
|
54
|
-
// these; only suite and result paths vary. Host-provided command strings are
|
|
55
|
-
// never executed.
|
|
56
|
-
function runSuiteByRunner(projectRoot, runner, suitePath) {
|
|
87
|
+
function runStructuralByRunner(projectRoot, runner, suitePath) {
|
|
57
88
|
switch (runner) {
|
|
58
89
|
case 'rspec':
|
|
59
90
|
return runRspecSuite(projectRoot, suitePath);
|
|
@@ -62,119 +93,15 @@ function runSuiteByRunner(projectRoot, runner, suitePath) {
|
|
|
62
93
|
case 'pytest':
|
|
63
94
|
return runPytestSuite(projectRoot, suitePath);
|
|
64
95
|
default:
|
|
65
|
-
return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite
|
|
96
|
+
return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite.`));
|
|
66
97
|
}
|
|
67
98
|
}
|
|
68
|
-
|
|
69
|
-
// the app under test cannot pollute. A run that produced no report is a
|
|
70
|
-
// structured suite error — command, exit code, expected result path, output
|
|
71
|
-
// tail — which Rails records without repainting capabilities.
|
|
72
|
-
function rawRunPayload(suiteDigest, runner, result) {
|
|
73
|
-
const report = boundedReport(runner, result);
|
|
74
|
-
if (report !== null)
|
|
75
|
-
return { suite_digest: suiteDigest, run_result: report };
|
|
99
|
+
function suiteError(suiteDigest, message) {
|
|
76
100
|
return {
|
|
77
101
|
suite_digest: suiteDigest,
|
|
78
|
-
suite_error: {
|
|
79
|
-
command: [result.command, ...result.args].join(' '),
|
|
80
|
-
exit_code: result.code,
|
|
81
|
-
result_path: result.resultPath,
|
|
82
|
-
output_tail: outputTail(result),
|
|
83
|
-
},
|
|
102
|
+
suite_error: { command: '', exit_code: null, result_path: '', output_tail: message },
|
|
84
103
|
};
|
|
85
104
|
}
|
|
86
|
-
// JSON reports (rspec, vitest) are parsed only to size-bound failure text, then
|
|
87
|
-
// re-serialized; the XML report is bounded per failure element the same way.
|
|
88
|
-
// `null` means "no usable report" — the suite-error path. Stdout is only a
|
|
89
|
-
// fallback for an rspec double that emits its report there.
|
|
90
|
-
function boundedReport(runner, result) {
|
|
91
|
-
if (runner === 'pytest')
|
|
92
|
-
return result.report.trim() ? boundJunitXml(result.report) : null;
|
|
93
|
-
const parsed = parseJsonObject(result.report) ?? (runner === 'rspec' ? parseJsonObject(result.stdout) : null);
|
|
94
|
-
if (!parsed)
|
|
95
|
-
return null;
|
|
96
|
-
const bounded = runner === 'rspec' ? boundRspecFailures(parsed) : boundVitestFailures(parsed);
|
|
97
|
-
return JSON.stringify(bounded);
|
|
98
|
-
}
|
|
99
|
-
// Truncate long per-example failure message/backtrace before transport. Other
|
|
100
|
-
// fields pass through untouched; Rails owns the capability join and status mapping.
|
|
101
|
-
function boundRspecFailures(rspecJson) {
|
|
102
|
-
const examples = rspecJson.examples;
|
|
103
|
-
if (!Array.isArray(examples))
|
|
104
|
-
return rspecJson;
|
|
105
|
-
const bounded = examples.map((example) => {
|
|
106
|
-
if (!example || typeof example !== 'object')
|
|
107
|
-
return example;
|
|
108
|
-
const ex = example;
|
|
109
|
-
const exception = ex.exception;
|
|
110
|
-
if (!exception || typeof exception !== 'object')
|
|
111
|
-
return ex;
|
|
112
|
-
const exc = exception;
|
|
113
|
-
const next = { ...exc };
|
|
114
|
-
if (typeof exc.message === 'string')
|
|
115
|
-
next.message = truncate(exc.message);
|
|
116
|
-
if (Array.isArray(exc.backtrace))
|
|
117
|
-
next.backtrace = exc.backtrace.slice(0, 20);
|
|
118
|
-
return { ...ex, exception: next };
|
|
119
|
-
});
|
|
120
|
-
return { ...rspecJson, examples: bounded };
|
|
121
|
-
}
|
|
122
|
-
function boundVitestFailures(vitestJson) {
|
|
123
|
-
const testResults = vitestJson.testResults;
|
|
124
|
-
if (!Array.isArray(testResults))
|
|
125
|
-
return vitestJson;
|
|
126
|
-
const bounded = testResults.map((fileResult) => {
|
|
127
|
-
if (!fileResult || typeof fileResult !== 'object')
|
|
128
|
-
return fileResult;
|
|
129
|
-
const file = fileResult;
|
|
130
|
-
const assertions = file.assertionResults;
|
|
131
|
-
if (!Array.isArray(assertions))
|
|
132
|
-
return file;
|
|
133
|
-
const next = assertions.map((assertion) => {
|
|
134
|
-
if (!assertion || typeof assertion !== 'object')
|
|
135
|
-
return assertion;
|
|
136
|
-
const a = assertion;
|
|
137
|
-
if (!Array.isArray(a.failureMessages))
|
|
138
|
-
return a;
|
|
139
|
-
return { ...a, failureMessages: a.failureMessages.map((m) => (typeof m === 'string' ? truncate(m) : m)) };
|
|
140
|
-
});
|
|
141
|
-
return { ...file, assertionResults: next };
|
|
142
|
-
});
|
|
143
|
-
return { ...vitestJson, testResults: bounded };
|
|
144
|
-
}
|
|
145
|
-
function truncate(text) {
|
|
146
|
-
return text.length > MAX_FAILURE_CHARS ? `${text.slice(0, MAX_FAILURE_CHARS)}… (truncated)` : text;
|
|
147
|
-
}
|
|
148
|
-
// pytest's JUnit XML has no size bound of its own: one failing assertion can
|
|
149
|
-
// carry a multi-megabyte diff plus captured stdout/stderr. Bound the two things
|
|
150
|
-
// Rails reads (the failure/error/skipped `message` attribute and the element
|
|
151
|
-
// body) plus the captured-output blocks, keeping the document well-formed so
|
|
152
|
-
// Rails' strict XML parse still succeeds.
|
|
153
|
-
function boundJunitXml(xml) {
|
|
154
|
-
const boundedBodies = xml.replace(/(<(failure|error|skipped|system-out|system-err)\b[^>]*>)([\s\S]*?)(<\/\2>)/g, (_match, open, _tag, body, close) => `${boundXmlMessageAttr(open)}${boundXmlText(body)}${close}`);
|
|
155
|
-
// Self-closing forms (e.g. <failure message="…"/>) carry everything in the attr.
|
|
156
|
-
return boundedBodies.replace(/<(failure|error|skipped)\b[^>]*\/>/g, (tag) => boundXmlMessageAttr(tag));
|
|
157
|
-
}
|
|
158
|
-
function boundXmlMessageAttr(tag) {
|
|
159
|
-
// Attribute values escape their own quotes, so matching to the next `"` is safe.
|
|
160
|
-
return tag.replace(/(\bmessage=")([\s\S]*?)(")/, (_m, pre, value, post) => value.length > MAX_FAILURE_CHARS ? `${pre}${boundXmlText(value)}… (truncated)${post}` : `${pre}${value}${post}`);
|
|
161
|
-
}
|
|
162
|
-
function boundXmlText(text) {
|
|
163
|
-
return text.length > MAX_FAILURE_CHARS ? `${truncateXml(text)}… (truncated)` : text;
|
|
164
|
-
}
|
|
165
|
-
// Slice without cutting inside an XML entity, which would break a strict parse.
|
|
166
|
-
function truncateXml(text) {
|
|
167
|
-
return text.slice(0, MAX_FAILURE_CHARS).replace(/&[^;]*$/, '');
|
|
168
|
-
}
|
|
169
|
-
function parseJsonObject(text) {
|
|
170
|
-
try {
|
|
171
|
-
const parsed = JSON.parse(text);
|
|
172
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
105
|
function outputTail(result) {
|
|
179
106
|
const bits = [];
|
|
180
107
|
if (result.stderr.trim())
|
|
@@ -1,35 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { materializeHelper } from "../files/guardrails.js";
|
|
2
|
-
import { writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
4
|
+
import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
3
5
|
import { anyStackPrecheck } from "../runner/precheck.js";
|
|
4
6
|
import { Wire } from "../wire.js";
|
|
5
|
-
// Confirm at least one supported stack is present
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// (via WireError) with guidance to run `/unitbob map` first.
|
|
7
|
+
// Confirm at least one supported stack is present, materialize the Ruby boot
|
|
8
|
+
// helper a generated RSpec suite would require, then fetch both peer assignments
|
|
9
|
+
// (spec 32) and each branch's recipe, and write the host's task to
|
|
10
|
+
// `.unitbob/suite-build/request.json`. No model is called and no source is read
|
|
11
|
+
// here — that is the host's job, framed by the two generation recipes. An
|
|
12
|
+
// unsupported project stops with one actionable message and writes nothing; a
|
|
13
|
+
// no-current-map error from the server surfaces (via WireError) with guidance to
|
|
14
|
+
// rebuild the map first.
|
|
14
15
|
export async function suitePrepare(config, _args = [], deps) {
|
|
15
16
|
const wire = new Wire(config);
|
|
16
17
|
const actual = {
|
|
17
18
|
getRecipe: (name) => wire.getRecipe(name),
|
|
18
|
-
|
|
19
|
+
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
19
20
|
precheck: anyStackPrecheck,
|
|
21
|
+
ensureRunner: deps?.ensureRunner ?? (async () => ({ status: 'provisioned' })),
|
|
22
|
+
stdout: process.stdout,
|
|
20
23
|
...deps,
|
|
21
24
|
};
|
|
22
25
|
const check = actual.precheck(config.projectRoot);
|
|
23
26
|
if (!check.ok)
|
|
24
27
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
25
28
|
materializeHelper(config.projectRoot);
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
}
|
|
41
|
+
const branches = await Promise.all(packets.map(async (packet) => ({
|
|
42
|
+
suite_kind: packet.suite_kind,
|
|
43
|
+
source_digest: packet.source_digest,
|
|
44
|
+
path_root: packet.path_root,
|
|
45
|
+
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
46
|
+
assignment: packet.assignment,
|
|
47
|
+
})));
|
|
48
|
+
const request = writeSuiteBuildRequest(config.projectRoot, branches);
|
|
49
|
+
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
50
|
+
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
51
|
+
actual.stdout.write(`Next: build both peer suites (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
52
|
+
`write your answer to ${request.output_path} as a branches array, run each locally to green, ` +
|
|
53
|
+
'then run `unitbob put-suite-build`.\n');
|
|
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';
|
|
35
61
|
}
|