mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan N1 / spec §10 / §22.4 / §16 Phase 7.
apps/daemon/src/plugins/atoms/build-test.ts ships the daemon-side
implementation that the bundled plugins/_official/atoms/build-test
SKILL.md fragment locks the contract for:
runBuildTest({ cwd, buildCommand?, testCommand?, ... }) → BuildTestReport
The runner:
- Honours explicit overrides (highest priority).
- Otherwise infers commands from package.json scripts:
typecheck (or build) → `{pm} run typecheck`
test → `{pm} run test`
- Auto-picks the package manager from lockfile presence
(pnpm-lock.yaml → pnpm; yarn.lock → yarn; else npm).
- Skips a half cleanly when no command resolved, recording an
explicit reason. Skipped doesn't flip the matching signal off
(a project that opts out of typecheck still gets build.passing=true).
- Caps combined stdout+stderr at 1 MiB by default, marks
truncated output with '…[truncated]'.
- Per-command timeout 5min default, sends SIGTERM at expiry.
- Spawn function is pluggable so callers can inject a stub.
writeBuildTestReport() persists the canonical critique/build-test.json
+ critique/build-test.log layout the SKILL.md fragment promises.
UntilSignals gains 'build.passing' + 'tests.passing' so a plan can
write `until: 'build.passing && tests.passing'` directly. Closes
the spec §22.4 limit-1 gap that was blocking scenario 2 (code
migration) from natively expressing 'build/test pass = converged'.
The score axis (legacy critique.score) maps:
both pass → 5
only build pass → 3
both fail → 1
so existing pipelines that read critique.score keep working.
Daemon tests: 1521 → 1528 (+7 cases on plugins-build-test:
both-pass, build-fail, skip-with-reason, log truncation, npm
script inference, pnpm preference, JSON + log persistence).
Co-authored-by: Tom Huang <1043269994@qq.com>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
// Phase 7 entry slice / spec §10 / §22.4 — build-test runner.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { runBuildTest, writeBuildTestReport } from '../src/plugins/atoms/build-test.js';
|
|
|
|
let tmp: string;
|
|
|
|
beforeEach(async () => {
|
|
tmp = await mkdtemp(path.join(os.tmpdir(), 'od-build-test-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('runBuildTest — explicit overrides', () => {
|
|
it('reports status=passing when both commands exit 0', async () => {
|
|
const report = await runBuildTest({
|
|
cwd: tmp,
|
|
buildCommand: 'true',
|
|
testCommand: 'true',
|
|
});
|
|
expect(report.build.status).toBe('passing');
|
|
expect(report.tests.status).toBe('passing');
|
|
expect(report.signals['build.passing']).toBe(true);
|
|
expect(report.signals['tests.passing']).toBe(true);
|
|
expect(report.signals['critique.score']).toBe(5);
|
|
});
|
|
|
|
it('reports status=failing when the build command exits non-zero', async () => {
|
|
const report = await runBuildTest({
|
|
cwd: tmp,
|
|
buildCommand: 'exit 7',
|
|
testCommand: null,
|
|
});
|
|
expect(report.build.status).toBe('failing');
|
|
expect(report.build.exitCode).toBe(7);
|
|
expect(report.signals['build.passing']).toBe(false);
|
|
expect(report.signals['critique.score']).toBe(1);
|
|
});
|
|
|
|
it('skips a half cleanly when the command is null + records a reason', async () => {
|
|
const report = await runBuildTest({
|
|
cwd: tmp,
|
|
buildCommand: 'true',
|
|
testCommand: null,
|
|
});
|
|
expect(report.build.status).toBe('passing');
|
|
expect(report.tests.status).toBe('skipped');
|
|
expect(report.tests.reason).toMatch(/no test command/);
|
|
// Skipped does not flip the signal off.
|
|
expect(report.signals['tests.passing']).toBe(true);
|
|
// Score: build passed, tests skipped (count as passing) → 5.
|
|
expect(report.signals['critique.score']).toBe(5);
|
|
});
|
|
|
|
it('truncates the log when output exceeds the budget', async () => {
|
|
// Produce ~64 KiB of output; cap is 1 KiB.
|
|
const report = await runBuildTest({
|
|
cwd: tmp,
|
|
buildCommand: 'yes "X" | head -c 65536',
|
|
testCommand: null,
|
|
logBudgetBytes: 1024,
|
|
});
|
|
expect(report.build.status).toBe('passing');
|
|
expect(report.build.log.length).toBeLessThan(2_500);
|
|
expect(report.build.log).toContain('truncated');
|
|
});
|
|
});
|
|
|
|
describe('runBuildTest — package.json inference', () => {
|
|
it('derives commands from npm scripts when no override is supplied', async () => {
|
|
await writeFile(
|
|
path.join(tmp, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'fixture',
|
|
scripts: { typecheck: 'true', test: 'true' },
|
|
}),
|
|
);
|
|
const report = await runBuildTest({ cwd: tmp });
|
|
expect(report.build.command).toMatch(/^npm run typecheck$/);
|
|
expect(report.tests.command).toMatch(/^npm run test$/);
|
|
expect(report.build.status).toBe('passing');
|
|
expect(report.tests.status).toBe('passing');
|
|
});
|
|
|
|
it('prefers pnpm when pnpm-lock.yaml exists', async () => {
|
|
await writeFile(
|
|
path.join(tmp, 'package.json'),
|
|
JSON.stringify({ name: 'fixture', scripts: { test: 'true' } }),
|
|
);
|
|
await writeFile(path.join(tmp, 'pnpm-lock.yaml'), '');
|
|
const report = await runBuildTest({ cwd: tmp });
|
|
expect(report.tests.command).toMatch(/^pnpm run test$/);
|
|
expect(report.build.status).toBe('skipped');
|
|
});
|
|
});
|
|
|
|
describe('writeBuildTestReport', () => {
|
|
it('writes critique/build-test.json + critique/build-test.log under cwd', async () => {
|
|
const report = await runBuildTest({
|
|
cwd: tmp,
|
|
buildCommand: 'true',
|
|
testCommand: 'true',
|
|
});
|
|
const { jsonPath, logPath } = await writeBuildTestReport({ cwd: tmp, report });
|
|
const json = JSON.parse(await readFile(jsonPath, 'utf8'));
|
|
expect(json.build.status).toBe('passing');
|
|
expect(json.tests.status).toBe('passing');
|
|
expect(json.signals['build.passing']).toBe(true);
|
|
const log = await readFile(logPath, 'utf8');
|
|
expect(log).toContain('# build');
|
|
expect(log).toContain('# tests');
|
|
});
|
|
});
|