@auto-engineer/component-implementor-react 1.95.0
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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-type-check.log +4 -0
- package/CHANGELOG.md +109 -0
- package/LICENSE +10 -0
- package/dist/src/commands/implement-component.d.ts +45 -0
- package/dist/src/commands/implement-component.d.ts.map +1 -0
- package/dist/src/commands/implement-component.js +124 -0
- package/dist/src/commands/implement-component.js.map +1 -0
- package/dist/src/commands/implement-component.test.d.ts +2 -0
- package/dist/src/commands/implement-component.test.d.ts.map +1 -0
- package/dist/src/commands/implement-component.test.js +130 -0
- package/dist/src/commands/implement-component.test.js.map +1 -0
- package/dist/src/extract-code-block.d.ts +2 -0
- package/dist/src/extract-code-block.d.ts.map +1 -0
- package/dist/src/extract-code-block.js +7 -0
- package/dist/src/extract-code-block.js.map +1 -0
- package/dist/src/extract-code-block.test.d.ts +2 -0
- package/dist/src/extract-code-block.test.d.ts.map +1 -0
- package/dist/src/extract-code-block.test.js +28 -0
- package/dist/src/extract-code-block.test.js.map +1 -0
- package/dist/src/generate-component.d.ts +15 -0
- package/dist/src/generate-component.d.ts.map +1 -0
- package/dist/src/generate-component.js +39 -0
- package/dist/src/generate-component.js.map +1 -0
- package/dist/src/generate-component.test.d.ts +2 -0
- package/dist/src/generate-component.test.d.ts.map +1 -0
- package/dist/src/generate-component.test.js +77 -0
- package/dist/src/generate-component.test.js.map +1 -0
- package/dist/src/generate-story.d.ts +14 -0
- package/dist/src/generate-story.d.ts.map +1 -0
- package/dist/src/generate-story.js +35 -0
- package/dist/src/generate-story.js.map +1 -0
- package/dist/src/generate-story.test.d.ts +2 -0
- package/dist/src/generate-story.test.d.ts.map +1 -0
- package/dist/src/generate-story.test.js +62 -0
- package/dist/src/generate-story.test.js.map +1 -0
- package/dist/src/generate-test.d.ts +14 -0
- package/dist/src/generate-test.d.ts.map +1 -0
- package/dist/src/generate-test.js +38 -0
- package/dist/src/generate-test.js.map +1 -0
- package/dist/src/generate-test.test.d.ts +2 -0
- package/dist/src/generate-test.test.d.ts.map +1 -0
- package/dist/src/generate-test.test.js +66 -0
- package/dist/src/generate-test.test.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/ketchup-plan.md +13 -0
- package/package.json +25 -0
- package/src/commands/implement-component.test.ts +151 -0
- package/src/commands/implement-component.ts +190 -0
- package/src/extract-code-block.test.ts +33 -0
- package/src/extract-code-block.ts +6 -0
- package/src/generate-component.test.ts +93 -0
- package/src/generate-component.ts +57 -0
- package/src/generate-story.test.ts +75 -0
- package/src/generate-story.ts +51 -0
- package/src/generate-test.test.ts +78 -0
- package/src/generate-test.ts +55 -0
- package/src/index.ts +11 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +14 -0
package/ketchup-plan.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Ketchup Plan: component-implementor-react
|
|
2
|
+
|
|
3
|
+
## TODO
|
|
4
|
+
|
|
5
|
+
## DONE
|
|
6
|
+
|
|
7
|
+
- [x] Burst 1: Infrastructure — package.json, tsconfig.json, vitest.config.ts (8d0ef1f1)
|
|
8
|
+
- [x] Burst 2: extract-code-block.ts with test (c272077c)
|
|
9
|
+
- [x] Burst 3: generate-test.ts with test (ca284764)
|
|
10
|
+
- [x] Burst 4: generate-component.ts with test (2263623a)
|
|
11
|
+
- [x] Burst 5: generate-story.ts with test (ceaa70df)
|
|
12
|
+
- [x] Burst 6: implement-component command handler with test (150fcb02)
|
|
13
|
+
- [x] Burst 7: index.ts exports + type-check (a54bd956)
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@auto-engineer/component-implementor-react",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"main": "./dist/src/index.js",
|
|
5
|
+
"types": "./dist/src/index.d.ts",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"ai": "^6.0.0",
|
|
8
|
+
"debug": "^4.4.1",
|
|
9
|
+
"@auto-engineer/message-bus": "1.95.0",
|
|
10
|
+
"@auto-engineer/model-factory": "1.95.0"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^3.2.1"
|
|
14
|
+
},
|
|
15
|
+
"version": "1.95.0",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
|
|
21
|
+
"test": "vitest run --reporter=dot",
|
|
22
|
+
"type-check": "tsc --noEmit",
|
|
23
|
+
"release": "pnpm publish --no-git-checks"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('ai', () => ({
|
|
4
|
+
generateText: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('@auto-engineer/model-factory', () => ({
|
|
8
|
+
createModelFromEnv: vi.fn(() => 'mock-model'),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('node:fs/promises', () => ({
|
|
12
|
+
readFile: vi.fn(),
|
|
13
|
+
writeFile: vi.fn(),
|
|
14
|
+
mkdir: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('node:fs', () => ({
|
|
18
|
+
existsSync: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { generateText } from 'ai';
|
|
24
|
+
import { commandHandler, handleImplementComponent } from './implement-component';
|
|
25
|
+
|
|
26
|
+
function makeCommand(overrides: Record<string, unknown> = {}) {
|
|
27
|
+
return {
|
|
28
|
+
type: 'ImplementComponent' as const,
|
|
29
|
+
data: {
|
|
30
|
+
targetDir: '/project/client',
|
|
31
|
+
job: {
|
|
32
|
+
id: 'job_1',
|
|
33
|
+
dependsOn: [],
|
|
34
|
+
target: 'ImplementComponent' as const,
|
|
35
|
+
payload: {
|
|
36
|
+
componentId: 'my-button',
|
|
37
|
+
structure: ['renders a button element'],
|
|
38
|
+
rendering: ['shows spinner when loading'],
|
|
39
|
+
interaction: ['calls onClick handler'],
|
|
40
|
+
styling: ['uses primary class'],
|
|
41
|
+
storybookPath: 'src/components/ui/MyButton.stories.tsx',
|
|
42
|
+
files: { create: ['src/components/ui/MyButton.tsx'] },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
...overrides,
|
|
46
|
+
},
|
|
47
|
+
requestId: 'req-1',
|
|
48
|
+
correlationId: 'cor-1',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('implement-component', () => {
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('handleImplementComponent', () => {
|
|
58
|
+
it('generates 3 files and returns ComponentImplemented on success', async () => {
|
|
59
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
60
|
+
mockGenerateText
|
|
61
|
+
.mockResolvedValueOnce({ text: 'test file code' } as Awaited<ReturnType<typeof generateText>>)
|
|
62
|
+
.mockResolvedValueOnce({ text: 'component file code' } as Awaited<ReturnType<typeof generateText>>)
|
|
63
|
+
.mockResolvedValueOnce({ text: 'story file code' } as Awaited<ReturnType<typeof generateText>>);
|
|
64
|
+
|
|
65
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
66
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
67
|
+
|
|
68
|
+
const result = await handleImplementComponent(makeCommand());
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual({
|
|
71
|
+
type: 'ComponentImplemented',
|
|
72
|
+
data: {
|
|
73
|
+
name: 'MyButton',
|
|
74
|
+
componentPath: '/project/client/src/components/ui/MyButton.tsx',
|
|
75
|
+
testPath: '/project/client/src/components/ui/MyButton.test.tsx',
|
|
76
|
+
storyPath: '/project/client/src/components/ui/MyButton.stories.tsx',
|
|
77
|
+
filesCreated: [
|
|
78
|
+
'/project/client/src/components/ui/MyButton.test.tsx',
|
|
79
|
+
'/project/client/src/components/ui/MyButton.tsx',
|
|
80
|
+
'/project/client/src/components/ui/MyButton.stories.tsx',
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
timestamp: expect.any(Date),
|
|
84
|
+
requestId: 'req-1',
|
|
85
|
+
correlationId: 'cor-1',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(writeFile).toHaveBeenCalledTimes(3);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('reads existing component when modifying', async () => {
|
|
92
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
93
|
+
mockGenerateText
|
|
94
|
+
.mockResolvedValueOnce({ text: 'test code' } as Awaited<ReturnType<typeof generateText>>)
|
|
95
|
+
.mockResolvedValueOnce({ text: 'component code' } as Awaited<ReturnType<typeof generateText>>)
|
|
96
|
+
.mockResolvedValueOnce({ text: 'story code' } as Awaited<ReturnType<typeof generateText>>);
|
|
97
|
+
|
|
98
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
99
|
+
vi.mocked(readFile).mockResolvedValue('existing component code' as never);
|
|
100
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
101
|
+
|
|
102
|
+
const command = makeCommand({
|
|
103
|
+
job: {
|
|
104
|
+
id: 'job_2',
|
|
105
|
+
dependsOn: [],
|
|
106
|
+
target: 'ImplementComponent',
|
|
107
|
+
payload: {
|
|
108
|
+
componentId: 'my-button',
|
|
109
|
+
structure: ['renders a button'],
|
|
110
|
+
rendering: [],
|
|
111
|
+
interaction: [],
|
|
112
|
+
styling: [],
|
|
113
|
+
storybookPath: 'src/components/ui/MyButton.stories.tsx',
|
|
114
|
+
files: { modify: ['src/components/ui/MyButton.tsx'] },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await handleImplementComponent(command);
|
|
120
|
+
|
|
121
|
+
expect(readFile).toHaveBeenCalledWith('/project/client/src/components/ui/MyButton.tsx', 'utf-8');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns ComponentImplementationFailed on error', async () => {
|
|
125
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
126
|
+
mockGenerateText.mockRejectedValue(new Error('AI service down'));
|
|
127
|
+
|
|
128
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
129
|
+
|
|
130
|
+
const result = await handleImplementComponent(makeCommand());
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({
|
|
133
|
+
type: 'ComponentImplementationFailed',
|
|
134
|
+
data: {
|
|
135
|
+
error: 'AI service down',
|
|
136
|
+
name: 'MyButton',
|
|
137
|
+
},
|
|
138
|
+
timestamp: expect.any(Date),
|
|
139
|
+
requestId: 'req-1',
|
|
140
|
+
correlationId: 'cor-1',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('commandHandler', () => {
|
|
146
|
+
it('has correct metadata', () => {
|
|
147
|
+
expect(commandHandler.name).toBe('ImplementComponent');
|
|
148
|
+
expect(commandHandler.alias).toBe('implement:component');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { type Command, defineCommandHandler, type Event } from '@auto-engineer/message-bus';
|
|
5
|
+
import createDebug from 'debug';
|
|
6
|
+
import { generateComponentFile } from '../generate-component';
|
|
7
|
+
import { generateStoryFile } from '../generate-story';
|
|
8
|
+
import { generateTestFile } from '../generate-test';
|
|
9
|
+
|
|
10
|
+
const debug = createDebug('auto:component-implementor-react:command');
|
|
11
|
+
|
|
12
|
+
type ComponentJobPayload = {
|
|
13
|
+
componentId: string;
|
|
14
|
+
structure: string[];
|
|
15
|
+
rendering: string[];
|
|
16
|
+
interaction: string[];
|
|
17
|
+
styling: string[];
|
|
18
|
+
storybookPath: string;
|
|
19
|
+
files: { create?: string[]; modify?: string[] };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ComponentJob = {
|
|
23
|
+
id: string;
|
|
24
|
+
dependsOn: string[];
|
|
25
|
+
target: 'ImplementComponent';
|
|
26
|
+
payload: ComponentJobPayload;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ImplementComponentCommand = Command<
|
|
30
|
+
'ImplementComponent',
|
|
31
|
+
{
|
|
32
|
+
targetDir: string;
|
|
33
|
+
job: ComponentJob;
|
|
34
|
+
}
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
export type ComponentImplementedEvent = Event<
|
|
38
|
+
'ComponentImplemented',
|
|
39
|
+
{
|
|
40
|
+
name: string;
|
|
41
|
+
componentPath: string;
|
|
42
|
+
testPath: string;
|
|
43
|
+
storyPath: string;
|
|
44
|
+
filesCreated: string[];
|
|
45
|
+
}
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
export type ComponentImplementationFailedEvent = Event<
|
|
49
|
+
'ComponentImplementationFailed',
|
|
50
|
+
{
|
|
51
|
+
error: string;
|
|
52
|
+
name: string;
|
|
53
|
+
}
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
export type ImplementComponentEvents = ComponentImplementedEvent | ComponentImplementationFailedEvent;
|
|
57
|
+
|
|
58
|
+
function pascalCase(id: string): string {
|
|
59
|
+
return id
|
|
60
|
+
.split('-')
|
|
61
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
62
|
+
.join('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deriveFilePaths(
|
|
66
|
+
targetDir: string,
|
|
67
|
+
payload: ComponentJobPayload,
|
|
68
|
+
): { componentPath: string; testPath: string; storyPath: string; componentName: string } {
|
|
69
|
+
const rawPath = payload.files.modify?.[0] ?? payload.files.create?.[0] ?? '';
|
|
70
|
+
const componentPath = path.resolve(targetDir, rawPath);
|
|
71
|
+
const dir = path.dirname(componentPath);
|
|
72
|
+
const componentName = pascalCase(payload.componentId);
|
|
73
|
+
const testPath = path.join(dir, `${componentName}.test.tsx`);
|
|
74
|
+
const storyPath = path.resolve(targetDir, payload.storybookPath);
|
|
75
|
+
|
|
76
|
+
return { componentPath, testPath, storyPath, componentName };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function handleImplementComponent(
|
|
80
|
+
command: ImplementComponentCommand,
|
|
81
|
+
): Promise<ComponentImplementedEvent | ComponentImplementationFailedEvent> {
|
|
82
|
+
const { targetDir, job } = command.data;
|
|
83
|
+
const { payload } = job;
|
|
84
|
+
const { componentPath, testPath, storyPath, componentName } = deriveFilePaths(targetDir, payload);
|
|
85
|
+
const isModify = (payload.files.modify?.length ?? 0) > 0;
|
|
86
|
+
|
|
87
|
+
debug('Implementing component: %s', componentName);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
let existingComponent: string | undefined;
|
|
91
|
+
if (isModify && existsSync(componentPath)) {
|
|
92
|
+
existingComponent = await readFile(componentPath, 'utf-8');
|
|
93
|
+
debug('Read existing component: %d chars', existingComponent.length);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const specDeltas = {
|
|
97
|
+
structure: payload.structure,
|
|
98
|
+
rendering: payload.rendering,
|
|
99
|
+
interaction: payload.interaction,
|
|
100
|
+
styling: payload.styling,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
debug('Generating test file...');
|
|
104
|
+
const testCode = await generateTestFile({
|
|
105
|
+
componentName,
|
|
106
|
+
specDeltas,
|
|
107
|
+
existingComponent,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
debug('Generating component file...');
|
|
111
|
+
const componentCode = await generateComponentFile({
|
|
112
|
+
componentName,
|
|
113
|
+
specDeltas,
|
|
114
|
+
testCode,
|
|
115
|
+
existingComponent,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
debug('Generating story file...');
|
|
119
|
+
const storyCode = await generateStoryFile({
|
|
120
|
+
componentName,
|
|
121
|
+
specDeltas,
|
|
122
|
+
componentCode,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await mkdir(path.dirname(testPath), { recursive: true });
|
|
126
|
+
await Promise.all([
|
|
127
|
+
writeFile(testPath, testCode, 'utf-8'),
|
|
128
|
+
writeFile(componentPath, componentCode, 'utf-8'),
|
|
129
|
+
writeFile(storyPath, storyCode, 'utf-8'),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
debug('Wrote 3 files for %s', componentName);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
type: 'ComponentImplemented',
|
|
136
|
+
data: {
|
|
137
|
+
name: componentName,
|
|
138
|
+
componentPath,
|
|
139
|
+
testPath,
|
|
140
|
+
storyPath,
|
|
141
|
+
filesCreated: [testPath, componentPath, storyPath],
|
|
142
|
+
},
|
|
143
|
+
timestamp: new Date(),
|
|
144
|
+
requestId: command.requestId,
|
|
145
|
+
correlationId: command.correlationId,
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
149
|
+
debug('Implementation failed: %s', errorMessage);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
type: 'ComponentImplementationFailed',
|
|
153
|
+
data: { error: errorMessage, name: componentName },
|
|
154
|
+
timestamp: new Date(),
|
|
155
|
+
requestId: command.requestId,
|
|
156
|
+
correlationId: command.correlationId,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const commandHandler = defineCommandHandler({
|
|
162
|
+
name: 'ImplementComponent',
|
|
163
|
+
displayName: 'Implement Component',
|
|
164
|
+
alias: 'implement:component',
|
|
165
|
+
description: 'Generate a React component with test and Storybook story from structured spec deltas',
|
|
166
|
+
category: 'implement',
|
|
167
|
+
icon: 'component',
|
|
168
|
+
fields: {
|
|
169
|
+
targetDir: {
|
|
170
|
+
description: 'The client project root directory',
|
|
171
|
+
required: true,
|
|
172
|
+
},
|
|
173
|
+
job: {
|
|
174
|
+
description: 'ComponentJob with structured spec delta payload',
|
|
175
|
+
required: true,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
examples: [
|
|
179
|
+
'$ auto implement:component --target-dir=./client --job=\'{"id":"job_1","dependsOn":[],"target":"ImplementComponent","payload":{...}}\'',
|
|
180
|
+
],
|
|
181
|
+
events: [
|
|
182
|
+
{ name: 'ComponentImplemented', displayName: 'Component Implemented' },
|
|
183
|
+
{ name: 'ComponentImplementationFailed', displayName: 'Component Implementation Failed' },
|
|
184
|
+
],
|
|
185
|
+
handle: async (command: Command): Promise<ImplementComponentEvents> => {
|
|
186
|
+
return handleImplementComponent(command as ImplementComponentCommand);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
export default commandHandler;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractCodeBlock } from './extract-code-block';
|
|
3
|
+
|
|
4
|
+
describe('extractCodeBlock', () => {
|
|
5
|
+
it('returns plain text unchanged', () => {
|
|
6
|
+
expect(extractCodeBlock('const x = 1;')).toBe('const x = 1;');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('strips tsx fenced code block', () => {
|
|
10
|
+
const input = '```tsx\nconst x = 1;\n```';
|
|
11
|
+
expect(extractCodeBlock(input)).toBe('const x = 1;');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('strips typescript fenced code block', () => {
|
|
15
|
+
const input = '```typescript\nconst x = 1;\n```';
|
|
16
|
+
expect(extractCodeBlock(input)).toBe('const x = 1;');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('strips ts fenced code block', () => {
|
|
20
|
+
const input = '```ts\nconst x = 1;\n```';
|
|
21
|
+
expect(extractCodeBlock(input)).toBe('const x = 1;');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('strips bare fenced code block', () => {
|
|
25
|
+
const input = '```\nconst x = 1;\n```';
|
|
26
|
+
expect(extractCodeBlock(input)).toBe('const x = 1;');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('trims surrounding whitespace', () => {
|
|
30
|
+
const input = ' ```tsx\n const x = 1;\n ``` ';
|
|
31
|
+
expect(extractCodeBlock(input)).toBe('const x = 1;');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { generateComponentFile } from './generate-component';
|
|
3
|
+
|
|
4
|
+
vi.mock('ai', () => ({
|
|
5
|
+
generateText: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('@auto-engineer/model-factory', () => ({
|
|
9
|
+
createModelFromEnv: vi.fn(() => 'mock-model'),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { generateText } from 'ai';
|
|
13
|
+
|
|
14
|
+
const specDeltas = {
|
|
15
|
+
structure: ['renders a Button element'],
|
|
16
|
+
rendering: ['shows loading spinner when loading=true'],
|
|
17
|
+
interaction: ['calls onClick when clicked'],
|
|
18
|
+
styling: ['applies primary variant by default'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('generateComponentFile', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns generated component code from AI', async () => {
|
|
27
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
28
|
+
mockGenerateText.mockResolvedValue({
|
|
29
|
+
text: '```tsx\nexport function Button() { return <button />; }\n```',
|
|
30
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
31
|
+
|
|
32
|
+
const result = await generateComponentFile({
|
|
33
|
+
componentName: 'Button',
|
|
34
|
+
specDeltas,
|
|
35
|
+
testCode: 'import { render } from "@testing-library/react";',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result).toBe('export function Button() { return <button />; }');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('includes spec deltas and test code in the prompt', async () => {
|
|
42
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
43
|
+
mockGenerateText.mockResolvedValue({
|
|
44
|
+
text: 'component code',
|
|
45
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
46
|
+
|
|
47
|
+
await generateComponentFile({
|
|
48
|
+
componentName: 'Button',
|
|
49
|
+
specDeltas,
|
|
50
|
+
testCode: 'describe("Button", () => {})',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
|
54
|
+
expect(prompt).toContain('## Structure');
|
|
55
|
+
expect(prompt).toContain('renders a Button element');
|
|
56
|
+
expect(prompt).toContain('## Test File');
|
|
57
|
+
expect(prompt).toContain('describe("Button", () => {})');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('includes existing component code when provided', async () => {
|
|
61
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
62
|
+
mockGenerateText.mockResolvedValue({
|
|
63
|
+
text: 'updated code',
|
|
64
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
65
|
+
|
|
66
|
+
await generateComponentFile({
|
|
67
|
+
componentName: 'Button',
|
|
68
|
+
specDeltas,
|
|
69
|
+
testCode: 'test code',
|
|
70
|
+
existingComponent: 'export function Button() {}',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
|
74
|
+
expect(prompt).toContain('## Existing Component');
|
|
75
|
+
expect(prompt).toContain('export function Button() {}');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses system prompt referencing React component generation', async () => {
|
|
79
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
80
|
+
mockGenerateText.mockResolvedValue({
|
|
81
|
+
text: 'code',
|
|
82
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
83
|
+
|
|
84
|
+
await generateComponentFile({
|
|
85
|
+
componentName: 'Button',
|
|
86
|
+
specDeltas,
|
|
87
|
+
testCode: 'test',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const system = mockGenerateText.mock.calls[0][0].system as string;
|
|
91
|
+
expect(system).toContain('React');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createModelFromEnv } from '@auto-engineer/model-factory';
|
|
2
|
+
import { generateText } from 'ai';
|
|
3
|
+
import { extractCodeBlock } from './extract-code-block';
|
|
4
|
+
|
|
5
|
+
type SpecDeltas = {
|
|
6
|
+
structure: string[];
|
|
7
|
+
rendering: string[];
|
|
8
|
+
interaction: string[];
|
|
9
|
+
styling: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type GenerateComponentInput = {
|
|
13
|
+
componentName: string;
|
|
14
|
+
specDeltas: SpecDeltas;
|
|
15
|
+
testCode: string;
|
|
16
|
+
existingComponent?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function buildSpecSection(heading: string, items: string[]): string {
|
|
20
|
+
if (items.length === 0) return '';
|
|
21
|
+
return `## ${heading}\n${items.map((i) => `- ${i}`).join('\n')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildPrompt(input: GenerateComponentInput): string {
|
|
25
|
+
const sections = [
|
|
26
|
+
buildSpecSection('Structure', input.specDeltas.structure),
|
|
27
|
+
buildSpecSection('Rendering', input.specDeltas.rendering),
|
|
28
|
+
buildSpecSection('Interaction', input.specDeltas.interaction),
|
|
29
|
+
buildSpecSection('Styling', input.specDeltas.styling),
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join('\n\n');
|
|
33
|
+
|
|
34
|
+
const existingContext = input.existingComponent
|
|
35
|
+
? `\n\n## Existing Component\n\`\`\`tsx\n${input.existingComponent}\n\`\`\``
|
|
36
|
+
: '';
|
|
37
|
+
|
|
38
|
+
return `Component: ${input.componentName}\n\n${sections}${existingContext}\n\n## Test File\n\`\`\`tsx\n${input.testCode}\n\`\`\``;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SYSTEM_PROMPT = `You are a React component expert. Write a React component (.tsx) that satisfies the spec and passes the provided test.
|
|
42
|
+
|
|
43
|
+
Rules:
|
|
44
|
+
- Use functional components with TypeScript
|
|
45
|
+
- Export the component as a named export
|
|
46
|
+
- Use Tailwind CSS classes for styling
|
|
47
|
+
- The component must pass the provided test
|
|
48
|
+
- Return ONLY the component file code, no commentary`;
|
|
49
|
+
|
|
50
|
+
export async function generateComponentFile(input: GenerateComponentInput): Promise<string> {
|
|
51
|
+
const { text } = await generateText({
|
|
52
|
+
model: createModelFromEnv(),
|
|
53
|
+
system: SYSTEM_PROMPT,
|
|
54
|
+
prompt: buildPrompt(input),
|
|
55
|
+
});
|
|
56
|
+
return extractCodeBlock(text);
|
|
57
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { generateStoryFile } from './generate-story';
|
|
3
|
+
|
|
4
|
+
vi.mock('ai', () => ({
|
|
5
|
+
generateText: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('@auto-engineer/model-factory', () => ({
|
|
9
|
+
createModelFromEnv: vi.fn(() => 'mock-model'),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { generateText } from 'ai';
|
|
13
|
+
|
|
14
|
+
const specDeltas = {
|
|
15
|
+
structure: ['renders a Button element'],
|
|
16
|
+
rendering: ['shows loading spinner when loading=true'],
|
|
17
|
+
interaction: ['calls onClick when clicked'],
|
|
18
|
+
styling: ['applies primary variant by default'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('generateStoryFile', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns generated story code from AI', async () => {
|
|
27
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
28
|
+
mockGenerateText.mockResolvedValue({
|
|
29
|
+
text: '```tsx\nimport type { Meta } from "@storybook/react";\n```',
|
|
30
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
31
|
+
|
|
32
|
+
const result = await generateStoryFile({
|
|
33
|
+
componentName: 'Button',
|
|
34
|
+
specDeltas,
|
|
35
|
+
componentCode: 'export function Button() { return <button />; }',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result).toBe('import type { Meta } from "@storybook/react";');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('includes spec deltas and component code in the prompt', async () => {
|
|
42
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
43
|
+
mockGenerateText.mockResolvedValue({
|
|
44
|
+
text: 'story code',
|
|
45
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
46
|
+
|
|
47
|
+
await generateStoryFile({
|
|
48
|
+
componentName: 'Button',
|
|
49
|
+
specDeltas,
|
|
50
|
+
componentCode: 'export function Button() {}',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const prompt = mockGenerateText.mock.calls[0][0].prompt as string;
|
|
54
|
+
expect(prompt).toContain('## Structure');
|
|
55
|
+
expect(prompt).toContain('renders a Button element');
|
|
56
|
+
expect(prompt).toContain('## Component Code');
|
|
57
|
+
expect(prompt).toContain('export function Button() {}');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('uses system prompt referencing Storybook', async () => {
|
|
61
|
+
const mockGenerateText = vi.mocked(generateText);
|
|
62
|
+
mockGenerateText.mockResolvedValue({
|
|
63
|
+
text: 'code',
|
|
64
|
+
} as Awaited<ReturnType<typeof generateText>>);
|
|
65
|
+
|
|
66
|
+
await generateStoryFile({
|
|
67
|
+
componentName: 'Button',
|
|
68
|
+
specDeltas,
|
|
69
|
+
componentCode: 'code',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const system = mockGenerateText.mock.calls[0][0].system as string;
|
|
73
|
+
expect(system).toContain('Storybook');
|
|
74
|
+
});
|
|
75
|
+
});
|