mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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>
190 lines
8.2 KiB
TypeScript
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);
|
|
});
|
|
});
|