open-design/apps/daemon/tests/plugins-build-test.test.ts
Cursor Agent 5b6a0899ce
feat(plugins): build-test atom shell-out runner (Phase 7 entry slice)
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>
2026-05-09 14:30:36 +00:00

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');
});
});