open-design/apps/daemon/tests/plugins-code-migration-e2e.test.ts
Cursor Agent 999c6cd68a
test(plugins): full code-migration pipeline e2e smoke test (Phase 7-8)
Plan S2 / spec §1 / §10 / §20.3 / §21.3.2.

apps/daemon/tests/plugins-code-migration-e2e.test.ts exercises
every Phase 6/7/8 atom impl in sequence on a Next.js fixture repo,
proving the contract chain works end-to-end without any agent /
LLM in the loop:

  code-import     \u2192 code/index.json
  design-extract  \u2192 code/tokens.json (hex colour lifted)
  token-map       \u2192 token-map/colors.json (mapped to design system)
  rewrite-plan    \u2192 plan/{plan.md, ownership.json, steps.json,
                       meta.json}
  patch-edit      \u2192 components/Button.tsx mutated via unified diff;
                       plan/receipts/step-rewrite-button.json written
  build-test      \u2192 critique/build-test.json (no-op commands so we
                       don't shell out to a real toolchain in CI)
  diff-review     \u2192 review/{diff.patch, summary.md, decision.json,
                       meta.json}
  handoff         \u2192 ArtifactManifest.handoffKind='deployable-app'
                       (accept + both signals + cli exportTarget)

Also adds a second case covering the reject ladder rung
(reject + missing build-test \u2192 design-only).

This is the first integration test that walks every code-migration
atom in one go; it locks the inter-atom file contract so a future
PR can't break the chain by, say, renaming code/tokens.json or
adding a required field to plan/steps.json without updating every
downstream reader.

Daemon tests: 1639 \u2192 1641 (+2 cases on
plugins-code-migration-e2e: full chain ending on
deployable-app, reject \u2192 design-only).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:55:57 +00:00

190 lines
8.2 KiB
TypeScript

// Plan §3.S2 / spec §1 / §10 / §20.3 / §21.3.2 — code-migration pipeline e2e.
//
// Exercises every Phase 6/7/8 atom impl in sequence on a Next.js
// fixture repo:
//
// code-import → code/index.json
// design-extract → code/tokens.json
// token-map → token-map/{colors,...}.json + unmatched.json
// rewrite-plan → plan/{plan.md, ownership.json, steps.json, meta.json}
// patch-edit → mutates Button.tsx via a unified diff +
// plan/receipts/step-rewrite-button.json
// build-test → critique/build-test.json (skipped commands; passes)
// diff-review → review/{diff.patch, summary.md, decision.json, meta.json}
// handoff → ArtifactManifest with handoffKind='patch'
//
// The test does NOT run an actual `pnpm typecheck` / `pnpm test` —
// we pass `'true'` as the build/test command so the runner just
// records the no-op exit-0 receipt. The point is the pipe shape:
// every atom reads what the previous atom wrote, the audit trail
// chains through.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { ArtifactManifest } from '@open-design/contracts';
import { runCodeImport } from '../src/plugins/atoms/code-import.js';
import { runDesignExtract } from '../src/plugins/atoms/design-extract.js';
import { runTokenMap, type DesignSystemTokenBag } from '../src/plugins/atoms/token-map.js';
import { runRewritePlan } from '../src/plugins/atoms/rewrite-plan.js';
import { applyPatchForStep } from '../src/plugins/atoms/patch-edit.js';
import { runBuildTest, writeBuildTestReport } from '../src/plugins/atoms/build-test.js';
import { runDiffReview } from '../src/plugins/atoms/diff-review.js';
import { runHandoffAtom } from '../src/plugins/atoms/handoff.js';
let repo: string;
let cwd: string;
const designSystem: DesignSystemTokenBag = {
id: 'fixture-ds',
tokens: [
{ name: '--ds-color-primary', value: '#5b8def', kind: 'color' },
{ name: '--ds-spacing-2', value: '16px', kind: 'spacing' },
],
};
beforeEach(async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), 'od-pipeline-e2e-'));
repo = path.join(tmp, 'repo');
cwd = path.join(tmp, 'cwd');
await mkdir(repo, { recursive: true });
await mkdir(cwd, { recursive: true });
// Tiny Next.js fixture: one leaf component carrying inline tokens.
await writeFile(path.join(repo, 'package.json'), JSON.stringify({
name: 'fixture',
dependencies: { next: '15', react: '18' },
devDependencies: { tailwindcss: '4', typescript: '5' },
}));
await writeFile(path.join(repo, 'pnpm-lock.yaml'), '');
await mkdir(path.join(repo, 'app'), { recursive: true });
await mkdir(path.join(repo, 'components'),{ recursive: true });
await writeFile(path.join(repo, 'app', 'page.tsx'),
`import Button from '@/components/Button';\nexport default function Page(){ return <Button />; }\n`);
await writeFile(path.join(repo, 'components', 'Button.tsx'),
`export default function Button() {\n return <button style={{ color: '#5b8def', padding: '16px' }} />;\n}\n`);
});
afterEach(async () => {
await rm(path.dirname(repo), { recursive: true, force: true });
});
describe('code-migration pipeline — full atom chain', () => {
it('runs every atom in sequence and ends on a patch-tier ArtifactManifest', async () => {
// 1. code-import.
const importIndex = await runCodeImport({ repoPath: repo, cwd });
expect(importIndex.framework).toBe('next');
expect(importIndex.files.map((f) => f.path)).toContain('components/Button.tsx');
// 2. design-extract.
const designReport = await runDesignExtract({ cwd, repoPath: repo });
const tokenValues = designReport.colors.map((t) => t.value);
expect(tokenValues).toContain('#5b8def');
// 3. token-map. design-extract picks up the inline hex but not
// the JSX-quoted spacing literal — that's expected, the
// SKILL.md fragment documents the regex's CSS-property bias.
// Color crosswalk is enough for this smoke chain.
const mapping = await runTokenMap({ cwd, designSystem });
expect(mapping.colors[0]?.target).toBe('--ds-color-primary');
// 4. rewrite-plan.
const plan = await runRewritePlan({ cwd, intent: 'tighten the brand' });
const buttonStep = plan.steps.find((s) => s.id === 'rewrite-button');
expect(buttonStep).toBeDefined();
expect(buttonStep?.files).toContain('components/Button.tsx');
// The build-test step is always last.
expect(plan.steps[plan.steps.length - 1]?.id).toBe('build-test');
// 5. patch-edit. We materialise the components/Button.tsx file
// inside the project cwd so the applier can read it and write
// the receipt back in-place. (rewrite-plan + earlier atoms
// only wrote planning artefacts under <cwd>.)
await mkdir(path.join(cwd, 'components'), { recursive: true });
await writeFile(path.join(cwd, 'components', 'Button.tsx'),
`export default function Button() {\n return <button style={{ color: '#5b8def', padding: '16px' }} />;\n}\n`);
const diff = `--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -1,3 +1,3 @@
export default function Button() {
- return <button style={{ color: '#5b8def', padding: '16px' }} />;
+ return <button style={{ color: 'var(--ds-color-primary)', padding: 'var(--ds-spacing-2)' }} />;
}
`;
const patch = await applyPatchForStep({
cwd,
stepId: 'rewrite-button',
diff,
rationale: 'tokens-alignment',
});
expect(patch.status).toBe('completed');
expect(patch.added).toBe(1);
expect(patch.removed).toBe(1);
const updated = await readFile(path.join(cwd, 'components', 'Button.tsx'), 'utf8');
expect(updated).toContain('var(--ds-color-primary)');
// 6. build-test (we override commands to no-ops so we don't shell
// out to a real toolchain in CI).
const buildReport = await runBuildTest({
cwd,
buildCommand: 'true',
testCommand: 'true',
});
expect(buildReport.signals['build.passing']).toBe(true);
expect(buildReport.signals['tests.passing']).toBe(true);
await writeBuildTestReport({ cwd, report: buildReport });
// 7. diff-review (with explicit decision).
const review = await runDiffReview({
cwd,
decision: { decision: 'accept', reviewer: 'user' },
});
expect(review.decision?.decision).toBe('accept');
expect(review.added).toBe(1);
expect(review.removed).toBe(1);
expect(review.files).toEqual(['components/Button.tsx']);
// 8. handoff. With accept + both build/test signals AND a 'cli'
// exportTarget, the manifest promotes to 'deployable-app'.
const initialManifest: ArtifactManifest = {
version: 1,
kind: 'react-component',
title: 'Button (re-tokenised)',
entry: 'components/Button.tsx',
renderer: 'react-component',
exports: [],
};
const handoff = await runHandoffAtom({
cwd,
manifest: initialManifest,
exportTarget: { surface: 'cli', target: '/tmp/od-export', exportedAt: Date.now() },
});
expect(handoff.signals.decision).toBe('accept');
expect(handoff.signals.buildPassing).toBe(true);
expect(handoff.signals.testsPassing).toBe(true);
expect(handoff.signals.deployable).toBe(true);
expect(handoff.manifest.handoffKind).toBe('deployable-app');
expect(handoff.manifest.exportTargets?.[0]?.surface).toBe('cli');
});
it('reject decision + missing build-test still demotes through the ladder cleanly', async () => {
await runCodeImport({ repoPath: repo, cwd });
await runDesignExtract({ cwd, repoPath: repo });
await runRewritePlan({ cwd });
// Skip patch-edit; jump straight to a 'reject' diff-review.
await runDiffReview({
cwd,
decision: { decision: 'reject', reviewer: 'user', reason: 'wrong direction' },
});
const handoff = await runHandoffAtom({
cwd,
manifest: {
version: 1, kind: 'react-component', title: 'X', entry: 'x.tsx',
renderer: 'react-component', exports: [],
},
});
expect(handoff.manifest.handoffKind).toBe('design-only');
expect(handoff.signals.deployable).toBe(false);
});
});