unitbob 0.1.8 → 0.1.9
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 +4 -0
- package/dist/config.js +15 -4
- package/dist/files/guardrails.js +30 -14
- package/dist/files/suiteBuild.js +41 -17
- package/dist/link.js +12 -18
- package/dist/runner/precheck.js +85 -4
- package/dist/runner/pytest.js +46 -0
- package/dist/runner/rspec.js +14 -19
- package/dist/runner/types.js +11 -0
- package/dist/runner/vitest.js +88 -0
- package/dist/verbs/putSuiteBuild.js +15 -5
- package/dist/verbs/run.js +113 -26
- package/dist/verbs/suitePrepare.js +11 -10
- package/dist/wire.js +12 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
A living map of your app's business parts. Each important seam gets an automatic
|
|
4
4
|
test — a "lamp" on the map. Green means fine, red means something broke.
|
|
5
5
|
|
|
6
|
+
Works with Ruby on Rails (RSpec), JavaScript/TypeScript (Vitest), and Python
|
|
7
|
+
(pytest) projects — the guardrail tests are generated in your project's own
|
|
8
|
+
language and run with its native test runner.
|
|
9
|
+
|
|
6
10
|
You work through Claude Code. You need: Node 18+, Python 3.10+.
|
|
7
11
|
|
|
8
12
|
---
|
package/dist/config.js
CHANGED
|
@@ -11,18 +11,29 @@ export const CONFIG_FILE = '.unitbob.json';
|
|
|
11
11
|
// missing, unreadable, malformed JSON, or repo_id absent / 0 / non-integer
|
|
12
12
|
// (the legacy init template wrote repo_id: 0). Callers re-link on null.
|
|
13
13
|
export function readLocalRepoId(cwd) {
|
|
14
|
+
const repoId = readConfigField(cwd, 'repo_id');
|
|
15
|
+
return typeof repoId === 'number' && Number.isInteger(repoId) && repoId > 0 ? repoId : null;
|
|
16
|
+
}
|
|
17
|
+
// The server URL stored at `cwd`, or null when the file is missing, malformed,
|
|
18
|
+
// or carries no usable http(s) URL. A local file naming a server must win over
|
|
19
|
+
// the built-in default — otherwise a locally-linked project silently talks to
|
|
20
|
+
// (and registers itself on) the public brain.
|
|
21
|
+
export function readLocalServer(cwd) {
|
|
22
|
+
const server = readConfigField(cwd, 'server');
|
|
23
|
+
return typeof server === 'string' && /^https?:\/\//.test(server.trim()) ? server.trim() : null;
|
|
24
|
+
}
|
|
25
|
+
function readConfigField(cwd, field) {
|
|
14
26
|
const path = join(cwd, CONFIG_FILE);
|
|
15
27
|
if (!existsSync(path))
|
|
16
|
-
return
|
|
28
|
+
return undefined;
|
|
17
29
|
let parsed;
|
|
18
30
|
try {
|
|
19
31
|
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
20
32
|
}
|
|
21
33
|
catch {
|
|
22
|
-
return
|
|
34
|
+
return undefined;
|
|
23
35
|
}
|
|
24
|
-
|
|
25
|
-
return typeof repoId === 'number' && Number.isInteger(repoId) && repoId > 0 ? repoId : null;
|
|
36
|
+
return parsed && typeof parsed === 'object' ? parsed[field] : undefined;
|
|
26
37
|
}
|
|
27
38
|
export function writeConfigFile(cwd, config) {
|
|
28
39
|
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
package/dist/files/guardrails.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
// A forward-slash literal, not path.join: suite paths always arrive over the
|
|
4
|
+
// wire with '/', while path.join would render this '.unitbob\guardrails' on
|
|
5
|
+
// Windows and make assertGuardrailPath reject every valid path there. node's
|
|
6
|
+
// path.join still accepts a '/'-joined segment as input on every platform, so
|
|
7
|
+
// filesystem builds below are unaffected.
|
|
8
|
+
export const GUARDRAILS_DIR = '.unitbob/guardrails';
|
|
5
9
|
export const HELPER_FILE = 'unitbob_helper.rb';
|
|
6
10
|
// An always-empty custom options file: pointing rspec's --options here keeps
|
|
7
11
|
// the project's own .rspec (stray --require lines, extra stdout formatters)
|
|
8
12
|
// out of guardrail runs, whose JSON output must stay parseable.
|
|
9
13
|
export const OPTIONS_FILE = 'rspec.opts';
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// The one place that decides whether a host-provided suite path is safe to
|
|
15
|
+
// write: relative, anchored under .unitbob/guardrails/, no traversal. Anything
|
|
16
|
+
// else throws and nothing is written.
|
|
17
|
+
export function assertGuardrailPath(path) {
|
|
18
|
+
const prefix = `${GUARDRAILS_DIR}/`;
|
|
19
|
+
const unsafe = !path || isAbsolute(path) || !path.startsWith(prefix) || path.split('/').some((segment) => segment === '..');
|
|
20
|
+
if (unsafe) {
|
|
21
|
+
throw new Error(`suite_file.path must be a relative path under ${prefix} with no traversal (got "${path}").`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// The boot file the generated Ruby suite requires (spec 29). Connector-owned and
|
|
16
25
|
// versioned with it — never scaffolded into the project, never part of the
|
|
17
|
-
// suite digest (that covers
|
|
26
|
+
// suite digest (that covers the suite_file only). Delegates to the project's
|
|
18
27
|
// own RSpec setup when one exists; boots the Rails test environment directly
|
|
19
28
|
// when none does. Both branches refuse a non-test environment — before boot
|
|
20
29
|
// via ENV (nothing touched yet), after boot via Rails.env (config overrides).
|
|
@@ -39,16 +48,23 @@ else
|
|
|
39
48
|
end
|
|
40
49
|
abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
|
|
41
50
|
`;
|
|
51
|
+
// Write the suite blob's guardrail file at its own (validated) relative path.
|
|
52
|
+
// The Ruby boot kit is materialized only for the rspec runner — Vitest and
|
|
53
|
+
// pytest runs need no connector-written support files here (the runtime
|
|
54
|
+
// pytest.ini lives outside this directory and is written by the pytest runner).
|
|
42
55
|
export function materializeGuardrails(projectRoot, suite) {
|
|
56
|
+
assertGuardrailPath(suite.suite_file.path);
|
|
43
57
|
const dir = join(projectRoot, GUARDRAILS_DIR);
|
|
44
58
|
rmSync(dir, { recursive: true, force: true });
|
|
45
59
|
mkdirSync(dir, { recursive: true });
|
|
46
|
-
const suitePath = join(
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
const suitePath = join(projectRoot, suite.suite_file.path);
|
|
61
|
+
mkdirSync(dirname(suitePath), { recursive: true });
|
|
62
|
+
writeFileSync(suitePath, suite.suite_file.content);
|
|
63
|
+
if (suite.runner_manifest.runner === 'rspec')
|
|
64
|
+
materializeHelper(projectRoot);
|
|
49
65
|
return { suitePath };
|
|
50
66
|
}
|
|
51
|
-
// Both flows boot the same way: the check flow writes the boot kit next to
|
|
67
|
+
// Both Ruby flows boot the same way: the check flow writes the boot kit next to
|
|
52
68
|
// the suite here, the suite-build flow writes it right after the precheck.
|
|
53
69
|
export function materializeHelper(projectRoot) {
|
|
54
70
|
const dir = join(projectRoot, GUARDRAILS_DIR);
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname,
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { assertGuardrailPath } from "./guardrails.js";
|
|
3
4
|
export function requestPath(projectRoot) {
|
|
4
5
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
5
6
|
}
|
|
@@ -30,34 +31,57 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
30
31
|
}
|
|
31
32
|
return request;
|
|
32
33
|
}
|
|
33
|
-
// Read and parse the host's answer. The host wrote and ran the whole
|
|
34
|
-
// the connector verifies
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// Read and parse the host's answer. The host wrote and ran the whole guardrail
|
|
35
|
+
// file; the connector verifies the answer parses, carries `suite_file` (with a
|
|
36
|
+
// safe path under .unitbob/guardrails/), `runner_manifest`, and
|
|
37
|
+
// `test_metadata`, then relays it untouched. Anything unparseable or
|
|
38
|
+
// incomplete means nothing is uploaded (all-or-nothing).
|
|
37
39
|
export function readHostSuiteOutput(path, projectRoot) {
|
|
38
40
|
if (!existsSync(path)) {
|
|
39
41
|
throw new Error(`${path} not found — the host suite builder did not write its output.`);
|
|
40
42
|
}
|
|
41
43
|
const parsed = parseJson(readFileSync(path, 'utf8'), path);
|
|
42
44
|
if (!parsed || typeof parsed !== 'object') {
|
|
43
|
-
throw new Error(`${path} is malformed: expected an object with
|
|
45
|
+
throw new Error(`${path} is malformed: expected an object with suite_file, runner_manifest, and test_metadata.`);
|
|
46
|
+
}
|
|
47
|
+
if ('spec_rb' in parsed || 'spec_rb_path' in parsed) {
|
|
48
|
+
throw new Error(`${path} uses the legacy spec_rb shape — emit suite_file { path, content } instead (spec 30).`);
|
|
44
49
|
}
|
|
45
50
|
if (!('test_metadata' in parsed)) {
|
|
46
51
|
throw new Error(`${path} is malformed: missing test_metadata.`);
|
|
47
52
|
}
|
|
48
|
-
const
|
|
49
|
-
|
|
53
|
+
const manifest = parsed.runner_manifest;
|
|
54
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
55
|
+
throw new Error(`${path} is malformed: missing runner_manifest.`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
suite_file: resolveSuiteFile(parsed, projectRoot, path),
|
|
59
|
+
runner_manifest: manifest,
|
|
60
|
+
test_metadata: parsed.test_metadata,
|
|
61
|
+
};
|
|
50
62
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
// The host may inline `content` or point at the file it already wrote at
|
|
64
|
+
// `suite_file.path` — either way the path must be safe before anything is read.
|
|
65
|
+
function resolveSuiteFile(parsed, projectRoot, path) {
|
|
66
|
+
const file = parsed.suite_file;
|
|
67
|
+
if (!file || typeof file !== 'object') {
|
|
68
|
+
throw new Error(`${path} is malformed: expected suite_file { path, content }.`);
|
|
69
|
+
}
|
|
70
|
+
const suiteFile = file;
|
|
71
|
+
const suitePath = typeof suiteFile.path === 'string' ? suiteFile.path : '';
|
|
72
|
+
assertGuardrailPath(suitePath);
|
|
73
|
+
if (typeof suiteFile.content === 'string' && suiteFile.content.trim()) {
|
|
74
|
+
return { path: suitePath, content: suiteFile.content };
|
|
75
|
+
}
|
|
76
|
+
const onDisk = join(projectRoot, suitePath);
|
|
77
|
+
if (!existsSync(onDisk)) {
|
|
78
|
+
throw new Error(`${path}: suite_file has no content and "${suitePath}" does not exist in the project.`);
|
|
79
|
+
}
|
|
80
|
+
const content = readFileSync(onDisk, 'utf8');
|
|
81
|
+
if (!content.trim()) {
|
|
82
|
+
throw new Error(`${path}: suite_file content at "${suitePath}" is empty.`);
|
|
59
83
|
}
|
|
60
|
-
|
|
84
|
+
return { path: suitePath, content };
|
|
61
85
|
}
|
|
62
86
|
function parseJson(raw, path) {
|
|
63
87
|
try {
|
package/dist/link.js
CHANGED
|
@@ -5,35 +5,29 @@
|
|
|
5
5
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
8
|
-
import { CONFIG_FILE, readLocalRepoId, writeConfigFile } from "./config.js";
|
|
8
|
+
import { CONFIG_FILE, readLocalRepoId, readLocalServer, writeConfigFile } from "./config.js";
|
|
9
9
|
import { registerRepo, WireError } from "./wire.js";
|
|
10
|
-
// Public Unitbob brain used by default.
|
|
11
|
-
// explicit
|
|
10
|
+
// Public Unitbob brain used by default. A `server` in `.unitbob.json` (or an
|
|
11
|
+
// explicit argument) overrides it — see resolution order in ensureLinked.
|
|
12
12
|
export const DEFAULT_SERVER = 'https://unitbob-73a4082838d3.herokuapp.com';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// - no working link (file missing / repo_id 0 / non-int) → register, write
|
|
16
|
-
// the file, announce in one calm line;
|
|
17
|
-
// - the file's id matches the name's server id → proceed silently;
|
|
18
|
-
// - mismatch → fail here, before any expensive work — never silently re-link,
|
|
19
|
-
// the file may point at a real repo the user cares about.
|
|
20
|
-
export async function ensureLinked(cwd = process.cwd(), server = DEFAULT_SERVER) {
|
|
13
|
+
export async function ensureLinked(cwd = process.cwd(), server, out = process.stdout) {
|
|
14
|
+
const resolvedServer = server ?? readLocalServer(cwd) ?? DEFAULT_SERVER;
|
|
21
15
|
const fileId = readLocalRepoId(cwd); // only cwd's own file — no walk-up
|
|
22
16
|
const name = projectName(cwd);
|
|
23
17
|
// Refuse before touching the server, so a stray run can't mint a junk repo.
|
|
24
18
|
if (fileId === null)
|
|
25
19
|
assertProjectRoot(cwd);
|
|
26
|
-
const authId = await registerRepo(
|
|
20
|
+
const authId = await registerRepo(resolvedServer, name);
|
|
27
21
|
if (fileId === null) {
|
|
28
|
-
writeConfigFile(cwd, { server, repo_id: authId });
|
|
29
|
-
ensureGitignored(cwd);
|
|
30
|
-
|
|
22
|
+
writeConfigFile(cwd, { server: resolvedServer, repo_id: authId });
|
|
23
|
+
ensureGitignored(cwd, out);
|
|
24
|
+
out.write(`Linked this project to Unitbob as ${name}.\n`);
|
|
31
25
|
}
|
|
32
26
|
else if (fileId !== authId) {
|
|
33
27
|
throw new WireError(`${CONFIG_FILE} points at repo ${fileId}, but "${name}" is repo ${authId} on the server. ` +
|
|
34
28
|
`Fix or remove ${CONFIG_FILE} before continuing.`);
|
|
35
29
|
}
|
|
36
|
-
return { server, repoId: authId, projectRoot: cwd };
|
|
30
|
+
return { server: resolvedServer, repoId: authId, projectRoot: cwd };
|
|
37
31
|
}
|
|
38
32
|
// The linking name is the *project's* name, not the checkout's (spec 29). A
|
|
39
33
|
// `.git` directory means cwd is the main checkout; a `.git` file is a worktree
|
|
@@ -113,7 +107,7 @@ function enclosingCheckout(cwd) {
|
|
|
113
107
|
const ARTIFACT_DIR = '.unitbob/';
|
|
114
108
|
const GITIGNORE = '.gitignore';
|
|
115
109
|
// The config and the artifact dir are per-machine state, never committed.
|
|
116
|
-
export function ensureGitignored(cwd) {
|
|
110
|
+
export function ensureGitignored(cwd, out = process.stdout) {
|
|
117
111
|
const gitignorePath = join(cwd, GITIGNORE);
|
|
118
112
|
let current = '';
|
|
119
113
|
if (existsSync(gitignorePath)) {
|
|
@@ -125,5 +119,5 @@ export function ensureGitignored(cwd) {
|
|
|
125
119
|
return;
|
|
126
120
|
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
127
121
|
writeFileSync(gitignorePath, `${current}${prefix}${additions.join('\n')}\n`);
|
|
128
|
-
|
|
122
|
+
out.write(`Added ${additions.join(', ')} to ${GITIGNORE}.\n`);
|
|
129
123
|
}
|
package/dist/runner/precheck.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
const defaultDeps = {
|
|
5
|
+
commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
|
|
6
|
+
};
|
|
7
|
+
const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
|
|
8
|
+
// The generation-time gate: at least one supported stack must be present.
|
|
9
|
+
export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
|
|
10
|
+
const supported = ['rspec', 'vitest', 'pytest'].some((runner) => validateStack(projectRoot, runner, deps).ok);
|
|
11
|
+
if (supported)
|
|
12
|
+
return { ok: true };
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// Confirm the host-selected runner against local markers. A mismatch fails
|
|
19
|
+
// closed: the caller writes no files and uploads nothing.
|
|
20
|
+
export function validateStack(projectRoot, runner, deps = defaultDeps) {
|
|
21
|
+
switch (runner) {
|
|
22
|
+
case 'rspec':
|
|
23
|
+
return rubyPrecheck(projectRoot);
|
|
24
|
+
case 'vitest':
|
|
25
|
+
return vitestPrecheck(projectRoot);
|
|
26
|
+
case 'pytest':
|
|
27
|
+
return pytestPrecheck(projectRoot, deps);
|
|
28
|
+
default:
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
message: `Unsupported runner "${runner}" — Unitbob supports rspec, vitest, and pytest only.`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function rubyPrecheck(projectRoot) {
|
|
6
36
|
if (!hasGemfileWith(projectRoot, /\brails\b/)) {
|
|
7
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
message: 'The Ruby stack was selected, but this project does not look like Rails ' +
|
|
40
|
+
'(no `rails` gem found in Gemfile).',
|
|
41
|
+
};
|
|
8
42
|
}
|
|
9
43
|
// Specifically rspec-rails: the boot helper requires `rspec/rails`, so a
|
|
10
44
|
// bare `rspec` gem passes nothing downstream — stop with the honest offer.
|
|
@@ -18,6 +52,53 @@ export function runtimePrecheck(projectRoot) {
|
|
|
18
52
|
}
|
|
19
53
|
return { ok: true };
|
|
20
54
|
}
|
|
55
|
+
function vitestPrecheck(projectRoot) {
|
|
56
|
+
const packageJson = join(projectRoot, 'package.json');
|
|
57
|
+
if (!existsSync(packageJson)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
message: 'The JavaScript/TypeScript stack was selected, but this project has no package.json.',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const hasVitest = /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
|
|
64
|
+
existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
|
|
65
|
+
if (!hasVitest) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
message: 'JS/TS guardrails require Vitest (Jest is not supported in MVP v2), and vitest was not ' +
|
|
69
|
+
"found in this project's package.json or node_modules. Offer the user to add it " +
|
|
70
|
+
'(`npm i -D vitest`); change dependencies only with their consent, then retry.',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
function pytestPrecheck(projectRoot, deps) {
|
|
76
|
+
const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
|
|
77
|
+
const found = markers.some((name) => existsSync(join(projectRoot, name)));
|
|
78
|
+
if (!found) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
message: 'The Python stack was selected, but this project has none of ' +
|
|
82
|
+
`${markers.join(', ')} — it does not look like a Python project.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Spec 30 fails closed on runner availability: unlike marker files, pytest
|
|
86
|
+
// must actually be importable in the current interpreter, or every run would
|
|
87
|
+
// end as a "No module named pytest" suite error after files were written. We
|
|
88
|
+
// probe the same interpreters the pytest runner tries, in the same order, so
|
|
89
|
+
// the precheck and the run agree on whether pytest is runnable.
|
|
90
|
+
const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
|
|
91
|
+
if (!available) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
message: 'The Python stack was selected, but pytest is not importable in the current Python ' +
|
|
95
|
+
'environment. If your dependencies live in a virtualenv, activate it (e.g. ' +
|
|
96
|
+
'`source .venv/bin/activate`) before running Unitbob; otherwise install pytest ' +
|
|
97
|
+
'(`pip install pytest`), then retry.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
21
102
|
function hasGemfileWith(projectRoot, pattern) {
|
|
22
103
|
for (const name of ['Gemfile', 'gems.rb']) {
|
|
23
104
|
const path = join(projectRoot, name);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runProcess } from "../proc.js";
|
|
4
|
+
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
5
|
+
import { readReport } from "./types.js";
|
|
6
|
+
export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
|
+
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
|
|
8
|
+
// A minimal runtime config, created or overwritten before each run and passed
|
|
9
|
+
// via `-c` so the project's own addopts (e.g. --cov, -n auto) cannot break the
|
|
10
|
+
// guardrail run or its JUnit output. Connector-owned: never stored in Rails,
|
|
11
|
+
// never part of the suite digest.
|
|
12
|
+
export const PYTEST_INI_FILE = join('.unitbob', 'pytest.ini');
|
|
13
|
+
export const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
14
|
+
// Run the materialised Unitbob guardrail suite with pytest in the current
|
|
15
|
+
// Python environment (spec 30) — no guessing at Poetry/uv/virtualenv wrappers.
|
|
16
|
+
// Only the guardrail file runs; the JUnit XML report goes to --junit-xml, not
|
|
17
|
+
// stdout. The command is connector-owned: the suite artifact never carries a
|
|
18
|
+
// command string.
|
|
19
|
+
export async function runPytestSuite(projectRoot, suitePath) {
|
|
20
|
+
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
21
|
+
const command = await pickPython(projectRoot);
|
|
22
|
+
const args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, suitePath, `--junit-xml=${PYTEST_RESULT_FILE}`];
|
|
23
|
+
const result = await runProcess(command, args, {
|
|
24
|
+
cwd: projectRoot,
|
|
25
|
+
timeoutMs: PYTEST_TIMEOUT_MS,
|
|
26
|
+
env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
...result,
|
|
30
|
+
command,
|
|
31
|
+
args,
|
|
32
|
+
resultPath: PYTEST_RESULT_FILE,
|
|
33
|
+
report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// The interpreter that can actually run pytest: `python3` when pytest imports
|
|
37
|
+
// there (macOS/Linux ship no bare `python`), else `python`. Probing `-m pytest`
|
|
38
|
+
// rather than just `--version` keeps this in step with pytestPrecheck, so the
|
|
39
|
+
// run uses the same interpreter the precheck confirmed.
|
|
40
|
+
async function pickPython(projectRoot) {
|
|
41
|
+
const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
|
|
42
|
+
cwd: projectRoot,
|
|
43
|
+
timeoutMs: 10_000,
|
|
44
|
+
}).catch(() => ({ stdout: '', stderr: '', code: 1 }));
|
|
45
|
+
return probe.code === 0 ? 'python3' : 'python';
|
|
46
|
+
}
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { runProcess } from "../proc.js";
|
|
4
|
-
import { GUARDRAILS_DIR, OPTIONS_FILE
|
|
4
|
+
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
5
|
+
import { readReport } from "./types.js";
|
|
5
6
|
export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
7
|
// Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
|
|
7
8
|
// green→red flip can never come from run-order nondeterminism. It does not inherit
|
|
8
9
|
// the project's random ordering.
|
|
9
10
|
export const RSPEC_SEED = '1';
|
|
11
|
+
export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
|
|
10
12
|
// Run the materialised Unitbob guardrail suite (spec 26). Only this file runs —
|
|
11
13
|
// never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
|
|
12
14
|
// --options points at the materialized empty file so the project's own .rspec
|
|
13
15
|
// (a --require of a helper we replaced, an extra stdout formatter) can neither
|
|
14
16
|
// break the boot nor corrupt the JSON output. The JSON report goes to `--out`
|
|
15
17
|
// (a file), not stdout, so the app's own stdout writes during the run can never
|
|
16
|
-
// corrupt it.
|
|
17
|
-
export async function runRspecSuite(projectRoot) {
|
|
18
|
-
const suitePath = join(GUARDRAILS_DIR, SUITE_FILE);
|
|
18
|
+
// corrupt it. `suitePath` is the suite blob's own project-relative path.
|
|
19
|
+
export async function runRspecSuite(projectRoot, suitePath) {
|
|
19
20
|
const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
|
|
20
|
-
const resultPath = join(GUARDRAILS_DIR, RESULT_FILE);
|
|
21
21
|
const { result, command, args } = await invokeRspec(projectRoot, [
|
|
22
22
|
suitePath,
|
|
23
23
|
'--options',
|
|
@@ -29,20 +29,15 @@ export async function runRspecSuite(projectRoot) {
|
|
|
29
29
|
'--format',
|
|
30
30
|
'json',
|
|
31
31
|
'--out',
|
|
32
|
-
|
|
32
|
+
RSPEC_RESULT_FILE,
|
|
33
33
|
]);
|
|
34
|
-
return {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return '';
|
|
45
|
-
}
|
|
34
|
+
return {
|
|
35
|
+
...result,
|
|
36
|
+
command,
|
|
37
|
+
args,
|
|
38
|
+
resultPath: RSPEC_RESULT_FILE,
|
|
39
|
+
report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
|
|
40
|
+
};
|
|
46
41
|
}
|
|
47
42
|
// Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
|
|
48
43
|
// run sets RAILS_ENV=test so guardrails execute against the Rails test
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
// Read a report file back verbatim. A missing or unreadable file is a clean
|
|
3
|
+
// empty string, not a throw: the caller reports a structured suite error.
|
|
4
|
+
export function readReport(path) {
|
|
5
|
+
try {
|
|
6
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runProcess } from "../proc.js";
|
|
4
|
+
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
5
|
+
import { readReport } from "./types.js";
|
|
6
|
+
export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
|
+
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
|
|
8
|
+
// A connector-owned Vitest config, written next to .unitbob/ before a run when
|
|
9
|
+
// the project has its own config. Connector-owned: never stored in Rails, never
|
|
10
|
+
// part of the suite digest.
|
|
11
|
+
export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
|
|
12
|
+
// The project configs we inherit from, most specific first. Vitest reads a
|
|
13
|
+
// project's own config even when we pass `--config`, so we must merge ours with
|
|
14
|
+
// it rather than replace it (plugins, path aliases and resolve settings the
|
|
15
|
+
// guardrail file needs all live there).
|
|
16
|
+
const PROJECT_CONFIGS = [
|
|
17
|
+
'vitest.config.ts',
|
|
18
|
+
'vitest.config.mts',
|
|
19
|
+
'vitest.config.cts',
|
|
20
|
+
'vitest.config.js',
|
|
21
|
+
'vitest.config.mjs',
|
|
22
|
+
'vitest.config.cjs',
|
|
23
|
+
'vite.config.ts',
|
|
24
|
+
'vite.config.mts',
|
|
25
|
+
'vite.config.cts',
|
|
26
|
+
'vite.config.js',
|
|
27
|
+
'vite.config.mjs',
|
|
28
|
+
'vite.config.cjs',
|
|
29
|
+
];
|
|
30
|
+
// Run the materialised Unitbob guardrail suite with the project's own Vitest
|
|
31
|
+
// (spec 30). Only the guardrail file runs — the path argument filters the run.
|
|
32
|
+
//
|
|
33
|
+
// A bare `vitest run <file>` treats the path as a filter that is intersected
|
|
34
|
+
// with the project's `test.include`, so a project whose include does not cover
|
|
35
|
+
// `.unitbob/` would collect no tests. When the project has its own config we
|
|
36
|
+
// therefore write a tiny config that merges it and adds the guardrail file to
|
|
37
|
+
// `include`; the positional filter still narrows the run to that one file. With
|
|
38
|
+
// no project config, Vitest's default include already covers `.unitbob/`, so
|
|
39
|
+
// the bare command is correct and we write nothing.
|
|
40
|
+
//
|
|
41
|
+
// The JSON report goes to --outputFile, not stdout, so app logging can never
|
|
42
|
+
// corrupt it. The command is connector-owned: the suite artifact never carries
|
|
43
|
+
// a command string.
|
|
44
|
+
export async function runVitestSuite(projectRoot, suitePath) {
|
|
45
|
+
const configArgs = writeMergedConfig(projectRoot, suitePath);
|
|
46
|
+
const command = 'npx';
|
|
47
|
+
const args = ['vitest', 'run', suitePath, ...configArgs, '--reporter=json', `--outputFile=${VITEST_RESULT_FILE}`];
|
|
48
|
+
const result = await runProcess(command, args, {
|
|
49
|
+
cwd: projectRoot,
|
|
50
|
+
timeoutMs: VITEST_TIMEOUT_MS,
|
|
51
|
+
env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
command,
|
|
56
|
+
args,
|
|
57
|
+
resultPath: VITEST_RESULT_FILE,
|
|
58
|
+
report: readReport(join(projectRoot, VITEST_RESULT_FILE)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Returns the `--config` args to add, writing the merge config first. When the
|
|
62
|
+
// project has no config of its own there is nothing to inherit and nothing to
|
|
63
|
+
// override (defaults already cover .unitbob/), so we return no args.
|
|
64
|
+
function writeMergedConfig(projectRoot, suitePath) {
|
|
65
|
+
const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
66
|
+
if (!projectConfig)
|
|
67
|
+
return [];
|
|
68
|
+
writeFileSync(join(projectRoot, VITEST_CONFIG_FILE), mergedConfigSource(projectConfig, suitePath));
|
|
69
|
+
return ['--config', VITEST_CONFIG_FILE];
|
|
70
|
+
}
|
|
71
|
+
// The .unitbob/ config sits one level below the project root, so the project
|
|
72
|
+
// config is a `../` import. `mergeConfig` concatenates `include`, so the
|
|
73
|
+
// guardrail file joins the project's patterns instead of replacing them; the
|
|
74
|
+
// positional filter then isolates it. A function-form config is resolved first.
|
|
75
|
+
function mergedConfigSource(projectConfig, suitePath) {
|
|
76
|
+
return `// Written by the unitbob connector before each vitest run — do not edit.
|
|
77
|
+
import { mergeConfig } from 'vitest/config';
|
|
78
|
+
import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
|
|
79
|
+
|
|
80
|
+
const base = typeof projectConfig === 'function'
|
|
81
|
+
? await projectConfig({ command: 'serve', mode: 'test' })
|
|
82
|
+
: projectConfig;
|
|
83
|
+
|
|
84
|
+
export default mergeConfig(base, {
|
|
85
|
+
test: { include: [${JSON.stringify(suitePath)}] },
|
|
86
|
+
});
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import { readHostSuiteOutput, readSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
2
|
+
import { validateStack } from "../runner/precheck.js";
|
|
2
3
|
import { Wire } from "../wire.js";
|
|
3
4
|
// Read the task and the host's answer, verify the answer parses and carries the
|
|
4
|
-
// whole
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// whole suite artifact, confirm the host-selected stack against local project
|
|
6
|
+
// markers (fail closed — a mismatch uploads nothing), then upload
|
|
7
|
+
// `{ map_digest, suite_file, runner_manifest, test_metadata }`. `map_digest`
|
|
8
|
+
// comes from the task — never the host's answer — so the host cannot claim a
|
|
9
|
+
// different map than it was given. If the answer is unparseable or incomplete,
|
|
10
|
+
// nothing is uploaded and the previous suite stands.
|
|
8
11
|
export async function putSuiteBuild(config, _args = [], deps) {
|
|
9
12
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
10
13
|
const output = readHostSuiteOutput(request.output_path, config.projectRoot);
|
|
11
14
|
const actual = {
|
|
12
15
|
putSuiteBuild: (payload) => new Wire(config).putSuiteBuild(payload),
|
|
16
|
+
validateStack,
|
|
13
17
|
...deps,
|
|
14
18
|
};
|
|
19
|
+
const runner = String(output.runner_manifest.runner ?? '');
|
|
20
|
+
const check = actual.validateStack(config.projectRoot, runner);
|
|
21
|
+
if (!check.ok) {
|
|
22
|
+
throw new Error(check.message ?? `Local project does not match the selected runner "${runner}".`);
|
|
23
|
+
}
|
|
15
24
|
const result = await actual.putSuiteBuild({
|
|
16
25
|
map_digest: request.map_digest,
|
|
17
|
-
|
|
26
|
+
suite_file: output.suite_file,
|
|
27
|
+
runner_manifest: output.runner_manifest,
|
|
18
28
|
test_metadata: output.test_metadata,
|
|
19
29
|
});
|
|
20
30
|
const tallies = Object.entries(result.counts)
|
package/dist/verbs/run.js
CHANGED
|
@@ -1,60 +1,104 @@
|
|
|
1
1
|
import { materializeGuardrails } from "../files/guardrails.js";
|
|
2
|
-
import {
|
|
2
|
+
import { validateStack } from "../runner/precheck.js";
|
|
3
3
|
import { runRspecSuite } from "../runner/rspec.js";
|
|
4
|
+
import { runVitestSuite } from "../runner/vitest.js";
|
|
5
|
+
import { runPytestSuite } from "../runner/pytest.js";
|
|
4
6
|
import { Wire } from "../wire.js";
|
|
5
7
|
// Bound failure text for transport/storage hygiene (spec 26). This is a size
|
|
6
8
|
// limit only, not a privacy filter — application values in failure messages are
|
|
7
9
|
// accepted, not scrubbed.
|
|
8
|
-
const MAX_FAILURE_CHARS =
|
|
10
|
+
const MAX_FAILURE_CHARS = 8000;
|
|
11
|
+
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.
|
|
9
16
|
export async function run(config, _args, deps) {
|
|
10
17
|
const wire = new Wire(config);
|
|
11
18
|
const d = {
|
|
12
19
|
getSuite: () => wire.getSuite(),
|
|
13
20
|
postRun: (payload) => wire.postRun(payload),
|
|
14
21
|
materializeGuardrails,
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
runSuite: runSuiteByRunner,
|
|
23
|
+
validateStack,
|
|
17
24
|
stdout: process.stdout,
|
|
18
25
|
...deps,
|
|
19
26
|
};
|
|
20
|
-
const check = d.precheck(config.projectRoot);
|
|
21
|
-
if (!check.ok)
|
|
22
|
-
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
23
27
|
const suite = await d.getSuite();
|
|
24
28
|
if (suite === null) {
|
|
25
29
|
d.stdout.write('No Unitbob suite exists yet. Generate the test suite first, then run /unitbob check again.\n');
|
|
26
30
|
return;
|
|
27
31
|
}
|
|
32
|
+
// Fail closed before touching the working tree: a stack mismatch writes no files.
|
|
33
|
+
const runner = suite.runner_manifest.runner;
|
|
34
|
+
const check = d.validateStack(config.projectRoot, runner);
|
|
35
|
+
if (!check.ok)
|
|
36
|
+
throw new Error(check.message ?? `Local project does not match the suite runner "${runner}".`);
|
|
28
37
|
d.materializeGuardrails(config.projectRoot, suite);
|
|
29
|
-
const result = await d.
|
|
38
|
+
const result = await d.runSuite(config.projectRoot, runner, suite.suite_file.path).catch((err) => ({
|
|
30
39
|
stdout: '',
|
|
31
40
|
stderr: err.message,
|
|
32
41
|
code: null,
|
|
33
|
-
command:
|
|
42
|
+
command: runner,
|
|
34
43
|
args: [],
|
|
35
|
-
|
|
44
|
+
resultPath: '',
|
|
45
|
+
report: '',
|
|
36
46
|
}));
|
|
37
|
-
const payload = rawRunPayload(suite.suite_digest, result);
|
|
47
|
+
const payload = rawRunPayload(suite.suite_digest, runner, result);
|
|
38
48
|
const summary = await d.postRun(payload);
|
|
39
49
|
d.stdout.write(`${summary.summary}\n`);
|
|
40
50
|
if (summary.map_url)
|
|
41
51
|
d.stdout.write(`${summary.map_url}\n`);
|
|
42
52
|
}
|
|
43
|
-
// The
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
// The connector-owned strategy table: `runner_manifest.runner` names one of
|
|
54
|
+
// these; only suite and result paths vary. Host-provided command strings are
|
|
55
|
+
// never executed.
|
|
56
|
+
function runSuiteByRunner(projectRoot, runner, suitePath) {
|
|
57
|
+
switch (runner) {
|
|
58
|
+
case 'rspec':
|
|
59
|
+
return runRspecSuite(projectRoot, suitePath);
|
|
60
|
+
case 'vitest':
|
|
61
|
+
return runVitestSuite(projectRoot, suitePath);
|
|
62
|
+
case 'pytest':
|
|
63
|
+
return runPytestSuite(projectRoot, suitePath);
|
|
64
|
+
default:
|
|
65
|
+
return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite with /unitbob suite.`));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// The machine-readable report comes from the runner's own output file, which
|
|
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 };
|
|
50
76
|
return {
|
|
51
77
|
suite_digest: suiteDigest,
|
|
52
|
-
suite_error:
|
|
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
|
+
},
|
|
53
84
|
};
|
|
54
85
|
}
|
|
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
|
+
}
|
|
55
99
|
// Truncate long per-example failure message/backtrace before transport. Other
|
|
56
100
|
// fields pass through untouched; Rails owns the capability join and status mapping.
|
|
57
|
-
function
|
|
101
|
+
function boundRspecFailures(rspecJson) {
|
|
58
102
|
const examples = rspecJson.examples;
|
|
59
103
|
if (!Array.isArray(examples))
|
|
60
104
|
return rspecJson;
|
|
@@ -75,9 +119,53 @@ function boundFailures(rspecJson) {
|
|
|
75
119
|
});
|
|
76
120
|
return { ...rspecJson, examples: bounded };
|
|
77
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
|
+
}
|
|
78
145
|
function truncate(text) {
|
|
79
146
|
return text.length > MAX_FAILURE_CHARS ? `${text.slice(0, MAX_FAILURE_CHARS)}… (truncated)` : text;
|
|
80
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
|
+
}
|
|
81
169
|
function parseJsonObject(text) {
|
|
82
170
|
try {
|
|
83
171
|
const parsed = JSON.parse(text);
|
|
@@ -87,13 +175,12 @@ function parseJsonObject(text) {
|
|
|
87
175
|
return null;
|
|
88
176
|
}
|
|
89
177
|
}
|
|
90
|
-
function
|
|
178
|
+
function outputTail(result) {
|
|
91
179
|
const bits = [];
|
|
92
|
-
if (result.code !== 0 && result.code !== null)
|
|
93
|
-
bits.push(`exit ${result.code}`);
|
|
94
180
|
if (result.stderr.trim())
|
|
95
|
-
bits.push(
|
|
181
|
+
bits.push(result.stderr.trim());
|
|
96
182
|
if (result.stdout.trim())
|
|
97
|
-
bits.push(
|
|
98
|
-
|
|
183
|
+
bits.push(result.stdout.trim());
|
|
184
|
+
const joined = bits.join('\n');
|
|
185
|
+
return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
|
|
99
186
|
}
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { materializeHelper } from "../files/guardrails.js";
|
|
2
2
|
import { writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
3
|
-
import {
|
|
3
|
+
import { anyStackPrecheck } from "../runner/precheck.js";
|
|
4
4
|
import { Wire } from "../wire.js";
|
|
5
|
-
// Confirm
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
5
|
+
// Confirm at least one supported stack is present (Rails+RSpec, Vitest, or
|
|
6
|
+
// pytest — the host LLM picks the primary one during generation), materialize
|
|
7
|
+
// the Ruby boot helper a generated RSpec suite would require, then fetch the
|
|
8
|
+
// generate recipe and the per-block capability assignment and write the host's
|
|
9
|
+
// task to `.unitbob/suite-build/request.json`. No model is called and no
|
|
10
|
+
// source is read here — that is the host's job, framed by
|
|
11
|
+
// ai/agents/suite_builder.md. An unsupported project stops with one actionable
|
|
12
|
+
// message and writes nothing; a no-current-map error from the server surfaces
|
|
13
|
+
// (via WireError) with guidance to run `/unitbob map` first.
|
|
13
14
|
export async function suitePrepare(config, _args = [], deps) {
|
|
14
15
|
const wire = new Wire(config);
|
|
15
16
|
const actual = {
|
|
16
17
|
getRecipe: (name) => wire.getRecipe(name),
|
|
17
18
|
getSuitePackets: () => wire.getSuitePackets(),
|
|
18
|
-
precheck:
|
|
19
|
+
precheck: anyStackPrecheck,
|
|
19
20
|
...deps,
|
|
20
21
|
};
|
|
21
22
|
const check = actual.precheck(config.projectRoot);
|
package/dist/wire.js
CHANGED
|
@@ -56,9 +56,9 @@ export class Wire {
|
|
|
56
56
|
return (await res.json());
|
|
57
57
|
}
|
|
58
58
|
// PUT /repos/:id/suite_build — upload the host's complete, locally-validated
|
|
59
|
-
// suite: the verbatim
|
|
60
|
-
//
|
|
61
|
-
// returns the new suite's identity.
|
|
59
|
+
// suite artifact: the verbatim suite_file, the suite-level runner_manifest,
|
|
60
|
+
// and the capability-keyed test_metadata. The server validates the artifact,
|
|
61
|
+
// stores it verbatim, versions, and returns the new suite's identity.
|
|
62
62
|
async putSuiteBuild(payload) {
|
|
63
63
|
const res = await this.send('PUT', this.repoPath('suite_build'), payload);
|
|
64
64
|
await this.ensureOk(res, `PUT ${this.repoPath('suite_build')}`);
|
|
@@ -118,9 +118,16 @@ export class Wire {
|
|
|
118
118
|
throw new WireError(`GET ${this.repoPath('suite')} returned a malformed suite payload.`);
|
|
119
119
|
}
|
|
120
120
|
const suite = payload;
|
|
121
|
-
|
|
121
|
+
const file = suite.suite_file;
|
|
122
|
+
const manifest = suite.runner_manifest;
|
|
123
|
+
const wellFormed = typeof suite.suite_digest === 'string' &&
|
|
124
|
+
file != null && typeof file === 'object' &&
|
|
125
|
+
typeof file.path === 'string' && typeof file.content === 'string' &&
|
|
126
|
+
manifest != null && typeof manifest === 'object' &&
|
|
127
|
+
typeof manifest.runner === 'string';
|
|
128
|
+
if (!wellFormed) {
|
|
122
129
|
throw new WireError(`GET ${this.repoPath('suite')} returned a malformed suite payload: ` +
|
|
123
|
-
'expected suite_digest and
|
|
130
|
+
'expected suite_digest, suite_file { path, content }, and runner_manifest.runner.');
|
|
124
131
|
}
|
|
125
132
|
return suite;
|
|
126
133
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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": {
|