@auto-engineer/component-implementor-react 1.97.2 → 1.98.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/package.json CHANGED
@@ -6,13 +6,13 @@
6
6
  "dependencies": {
7
7
  "ai": "^6.0.0",
8
8
  "debug": "^4.4.1",
9
- "@auto-engineer/message-bus": "1.97.2",
10
- "@auto-engineer/model-factory": "1.97.2"
9
+ "@auto-engineer/message-bus": "1.98.0",
10
+ "@auto-engineer/model-factory": "1.98.0"
11
11
  },
12
12
  "devDependencies": {
13
13
  "vitest": "^3.2.1"
14
14
  },
15
- "version": "1.97.2",
15
+ "version": "1.98.0",
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
@@ -149,6 +149,156 @@ describe('implement-component', () => {
149
149
  correlationId: 'cor-1',
150
150
  });
151
151
  });
152
+
153
+ it('handles payload with undefined spec delta fields without crashing', async () => {
154
+ const mockGenerateText = vi.mocked(generateText);
155
+ mockGenerateText
156
+ .mockResolvedValueOnce({ text: 'component file code' } as Awaited<ReturnType<typeof generateText>>)
157
+ .mockResolvedValueOnce({ text: 'test file code' } as Awaited<ReturnType<typeof generateText>>)
158
+ .mockResolvedValueOnce({ text: 'story file code' } as Awaited<ReturnType<typeof generateText>>)
159
+ .mockResolvedValueOnce({
160
+ text: '```tsx\nreconciled component\n```\n\n```tsx\nreconciled story\n```',
161
+ } as Awaited<ReturnType<typeof generateText>>);
162
+
163
+ vi.mocked(existsSync).mockReturnValue(false);
164
+ vi.mocked(writeFile).mockResolvedValue(undefined);
165
+
166
+ // Simulate production payload where the pipeline omits spec delta fields
167
+ // (e.g. "Resource-preview", "Plant-fields-page" components).
168
+ // At runtime JSON deserialization can produce undefined for these fields,
169
+ // bypassing TypeScript's compile-time guarantees.
170
+ const command = {
171
+ type: 'ImplementComponent' as const,
172
+ data: {
173
+ targetDir: '/project/client',
174
+ job: {
175
+ id: 'job_3',
176
+ dependsOn: [],
177
+ target: 'ImplementComponent' as const,
178
+ payload: {
179
+ componentId: 'resource-preview',
180
+ structure: ['renders a preview card'],
181
+ rendering: undefined,
182
+ interaction: undefined,
183
+ styling: undefined,
184
+ storybookPath: 'src/components/ui/ResourcePreview.stories.tsx',
185
+ files: { create: ['src/components/ui/ResourcePreview.tsx'] },
186
+ },
187
+ },
188
+ },
189
+ requestId: 'req-1',
190
+ correlationId: 'cor-1',
191
+ };
192
+
193
+ // Cast through unknown to bypass TypeScript — this is what happens at runtime
194
+ // when the pipeline sends a payload with missing fields
195
+ const result = await handleImplementComponent(
196
+ command as unknown as Parameters<typeof handleImplementComponent>[0],
197
+ );
198
+
199
+ // Should succeed, not crash. Undefined spec delta fields should be treated as empty arrays.
200
+ // Currently fails: buildSpecSection() throws "Cannot read properties of undefined (reading 'map')"
201
+ // which gets caught by try/catch and returns ComponentImplementationFailed instead.
202
+ expect(result.type).toBe('ComponentImplemented');
203
+ });
204
+
205
+ it('defaults targetDir to ./client when not provided', async () => {
206
+ const mockGenerateText = vi.mocked(generateText);
207
+ mockGenerateText
208
+ .mockResolvedValueOnce({ text: 'component code' } as Awaited<ReturnType<typeof generateText>>)
209
+ .mockResolvedValueOnce({ text: 'test code' } as Awaited<ReturnType<typeof generateText>>)
210
+ .mockResolvedValueOnce({ text: 'story code' } as Awaited<ReturnType<typeof generateText>>)
211
+ .mockResolvedValueOnce({
212
+ text: '```tsx\nrc\n```\n```tsx\nrs\n```',
213
+ } as Awaited<ReturnType<typeof generateText>>);
214
+
215
+ vi.mocked(existsSync).mockReturnValue(false);
216
+ vi.mocked(writeFile).mockResolvedValue(undefined);
217
+
218
+ const command = {
219
+ type: 'ImplementComponent' as const,
220
+ data: {
221
+ job: {
222
+ id: 'job_1',
223
+ dependsOn: [],
224
+ target: 'ImplementComponent' as const,
225
+ payload: {
226
+ componentId: 'my-widget',
227
+ structure: ['renders a widget'],
228
+ rendering: ['shows content'],
229
+ interaction: ['handles click'],
230
+ styling: ['uses primary'],
231
+ storybookPath: 'src/MyWidget.stories.tsx',
232
+ files: { create: ['src/MyWidget.tsx'] },
233
+ },
234
+ },
235
+ },
236
+ requestId: 'req-1',
237
+ correlationId: 'cor-1',
238
+ };
239
+
240
+ const result = await handleImplementComponent(
241
+ command as unknown as Parameters<typeof handleImplementComponent>[0],
242
+ );
243
+
244
+ expect(result).toEqual(
245
+ expect.objectContaining({
246
+ type: 'ComponentImplemented',
247
+ data: expect.objectContaining({ name: 'MyWidget' }),
248
+ }),
249
+ );
250
+ });
251
+
252
+ it('derives component path from componentId when files.create is empty string', async () => {
253
+ const mockGenerateText = vi.mocked(generateText);
254
+ mockGenerateText
255
+ .mockResolvedValueOnce({ text: 'component code' } as Awaited<ReturnType<typeof generateText>>)
256
+ .mockResolvedValueOnce({ text: 'test code' } as Awaited<ReturnType<typeof generateText>>)
257
+ .mockResolvedValueOnce({ text: 'story code' } as Awaited<ReturnType<typeof generateText>>)
258
+ .mockResolvedValueOnce({
259
+ text: '```tsx\nrc\n```\n```tsx\nrs\n```',
260
+ } as Awaited<ReturnType<typeof generateText>>);
261
+
262
+ vi.mocked(existsSync).mockReturnValue(false);
263
+ vi.mocked(writeFile).mockResolvedValue(undefined);
264
+
265
+ const command = makeCommand({
266
+ job: {
267
+ id: 'job_1',
268
+ dependsOn: [],
269
+ target: 'ImplementComponent',
270
+ payload: {
271
+ componentId: 'task-form-input',
272
+ structure: ['renders input'],
273
+ rendering: [],
274
+ interaction: [],
275
+ styling: [],
276
+ storybookPath: '.stories',
277
+ files: { create: [''] },
278
+ },
279
+ },
280
+ });
281
+
282
+ const result = await handleImplementComponent(command);
283
+
284
+ expect(result).toEqual({
285
+ type: 'ComponentImplemented',
286
+ data: {
287
+ name: 'TaskFormInput',
288
+ componentPath: expect.stringContaining('src/components/TaskFormInput.tsx'),
289
+ testPath: expect.stringContaining('src/components/TaskFormInput.test.tsx'),
290
+ storyPath: expect.stringContaining('src/components/TaskFormInput.stories.tsx'),
291
+ filesCreated: expect.arrayContaining([
292
+ expect.stringContaining('TaskFormInput.tsx'),
293
+ expect.stringContaining('TaskFormInput.test.tsx'),
294
+ expect.stringContaining('TaskFormInput.stories.tsx'),
295
+ ]),
296
+ },
297
+ timestamp: expect.any(Date),
298
+ requestId: 'req-1',
299
+ correlationId: 'cor-1',
300
+ });
301
+ });
152
302
  });
153
303
 
154
304
  describe('commandHandler', () => {
@@ -68,11 +68,15 @@ function deriveFilePaths(
68
68
  payload: ComponentJobPayload,
69
69
  ): { componentPath: string; testPath: string; storyPath: string; componentName: string } {
70
70
  const rawPath = payload.files.modify?.[0] ?? payload.files.create?.[0] ?? '';
71
- const componentPath = path.resolve(targetDir, rawPath);
72
- const dir = path.dirname(componentPath);
73
71
  const componentName = pascalCase(payload.componentId);
72
+ const effectivePath = rawPath || `src/components/${componentName}.tsx`;
73
+ const componentPath = path.resolve(targetDir, effectivePath);
74
+ const dir = path.dirname(componentPath);
74
75
  const testPath = path.join(dir, `${componentName}.test.tsx`);
75
- const storyPath = path.resolve(targetDir, payload.storybookPath);
76
+ const storyPath =
77
+ payload.storybookPath && payload.storybookPath !== '.stories'
78
+ ? path.resolve(targetDir, payload.storybookPath)
79
+ : path.join(dir, `${componentName}.stories.tsx`);
76
80
 
77
81
  return { componentPath, testPath, storyPath, componentName };
78
82
  }
@@ -80,7 +84,7 @@ function deriveFilePaths(
80
84
  export async function handleImplementComponent(
81
85
  command: ImplementComponentCommand,
82
86
  ): Promise<ComponentImplementedEvent | ComponentImplementationFailedEvent> {
83
- const { targetDir, job } = command.data;
87
+ const { targetDir = './client', job } = command.data;
84
88
  const { payload } = job;
85
89
  const { componentPath, testPath, storyPath, componentName } = deriveFilePaths(targetDir, payload);
86
90
  const isModify = (payload.files.modify?.length ?? 0) > 0;
@@ -131,6 +131,85 @@ describe('prompt builders', () => {
131
131
  });
132
132
  });
133
133
 
134
+ describe('buildComponentPrompt handles undefined specDelta fields', () => {
135
+ const specDeltasWithUndefined = {
136
+ structure: ['renders a Card element'],
137
+ rendering: undefined as unknown as string[],
138
+ interaction: undefined as unknown as string[],
139
+ styling: ['applies elevated style when raised=true'],
140
+ };
141
+
142
+ it('returns valid result when rendering and interaction are undefined', () => {
143
+ const result = buildComponentPrompt({
144
+ componentName: 'Card',
145
+ specDeltas: specDeltasWithUndefined,
146
+ });
147
+ expect(result.system).toBeTruthy();
148
+ expect(result.prompt).toBeTruthy();
149
+ expect(result.prompt).toContain('renders a Card element');
150
+ });
151
+ });
152
+
153
+ describe('buildTestPrompt handles undefined specDelta fields', () => {
154
+ const specDeltasWithUndefined = {
155
+ structure: undefined as unknown as string[],
156
+ rendering: ['shows skeleton when loading'],
157
+ interaction: undefined as unknown as string[],
158
+ styling: ['applies elevated style when raised=true'],
159
+ };
160
+
161
+ it('returns valid result when structure and interaction are undefined', () => {
162
+ const result = buildTestPrompt({
163
+ componentName: 'Card',
164
+ specDeltas: specDeltasWithUndefined,
165
+ });
166
+ expect(result.system).toBeTruthy();
167
+ expect(result.prompt).toBeTruthy();
168
+ expect(result.prompt).toContain('shows skeleton when loading');
169
+ });
170
+ });
171
+
172
+ describe('buildStoryPrompt handles undefined specDelta fields', () => {
173
+ const specDeltasWithUndefined = {
174
+ structure: ['renders a Card element'],
175
+ rendering: undefined as unknown as string[],
176
+ interaction: ['calls onSelect when clicked'],
177
+ styling: undefined as unknown as string[],
178
+ };
179
+
180
+ it('returns valid result when rendering and styling are undefined', () => {
181
+ const result = buildStoryPrompt({
182
+ componentName: 'Card',
183
+ specDeltas: specDeltasWithUndefined,
184
+ });
185
+ expect(result.system).toBeTruthy();
186
+ expect(result.prompt).toBeTruthy();
187
+ expect(result.prompt).toContain('renders a Card element');
188
+ });
189
+ });
190
+
191
+ describe('buildReconcilerPrompt handles undefined specDelta fields', () => {
192
+ const specDeltasWithUndefined = {
193
+ structure: ['renders a Card element'],
194
+ rendering: ['shows skeleton when loading'],
195
+ interaction: undefined as unknown as string[],
196
+ styling: undefined as unknown as string[],
197
+ };
198
+
199
+ it('returns valid result when interaction and styling are undefined', () => {
200
+ const result = buildReconcilerPrompt({
201
+ componentName: 'Card',
202
+ specDeltas: specDeltasWithUndefined,
203
+ componentCode: 'component source',
204
+ testCode: 'test source',
205
+ storyCode: 'story source',
206
+ });
207
+ expect(result.system).toBeTruthy();
208
+ expect(result.prompt).toBeTruthy();
209
+ expect(result.prompt).toContain('renders a Card element');
210
+ });
211
+ });
212
+
134
213
  describe('exported prompt sections', () => {
135
214
  it('component sections are all non-empty strings', () => {
136
215
  for (const value of Object.values(componentPromptSections)) {
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildSpecSection, buildSpecText, type SpecDeltas } from './spec-contract';
3
+
4
+ describe('buildSpecSection', () => {
5
+ it('returns empty string when items is undefined', () => {
6
+ const result = buildSpecSection('Structure', undefined as unknown as string[]);
7
+ expect(result).toBe('');
8
+ });
9
+ });
10
+
11
+ describe('buildSpecText', () => {
12
+ it('returns empty string when specDeltas fields are undefined', () => {
13
+ const specDeltas = {
14
+ structure: undefined,
15
+ rendering: ['renders heading'],
16
+ interaction: undefined,
17
+ styling: ['uses red background'],
18
+ } as unknown as SpecDeltas;
19
+
20
+ const result = buildSpecText(specDeltas);
21
+ expect(result).toContain('Rendering');
22
+ });
23
+ });
@@ -6,7 +6,7 @@ export type SpecDeltas = {
6
6
  };
7
7
 
8
8
  export function buildSpecSection(heading: string, items: string[]): string {
9
- if (items.length === 0) return '';
9
+ if (!items || items.length === 0) return '';
10
10
  return `## ${heading}\n${items.map((i) => `- ${i}`).join('\n')}`;
11
11
  }
12
12