@auto-engineer/component-implementor-react 1.102.0 → 1.103.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.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +5 -5
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +54 -0
  5. package/dist/src/commands/implement-component.d.ts.map +1 -1
  6. package/dist/src/commands/implement-component.js +4 -0
  7. package/dist/src/commands/implement-component.js.map +1 -1
  8. package/dist/src/commands/implement-component.test.js +52 -0
  9. package/dist/src/commands/implement-component.test.js.map +1 -1
  10. package/dist/src/pipeline/run-pipeline.d.ts +8 -0
  11. package/dist/src/pipeline/run-pipeline.d.ts.map +1 -1
  12. package/dist/src/pipeline/run-pipeline.js +8 -0
  13. package/dist/src/pipeline/run-pipeline.js.map +1 -1
  14. package/dist/src/pipeline/run-pipeline.test.js +39 -3
  15. package/dist/src/pipeline/run-pipeline.test.js.map +1 -1
  16. package/dist/src/pipeline/steps/generate-component.test.js +4 -0
  17. package/dist/src/pipeline/steps/generate-component.test.js.map +1 -1
  18. package/dist/src/pipeline/steps/generate-story.test.js +4 -0
  19. package/dist/src/pipeline/steps/generate-story.test.js.map +1 -1
  20. package/dist/src/pipeline/steps/generate-test.test.js +4 -0
  21. package/dist/src/pipeline/steps/generate-test.test.js.map +1 -1
  22. package/dist/src/pipeline/steps/lint-fix-loop.d.ts.map +1 -1
  23. package/dist/src/pipeline/steps/lint-fix-loop.js +4 -3
  24. package/dist/src/pipeline/steps/lint-fix-loop.js.map +1 -1
  25. package/dist/src/pipeline/steps/lint-fix-loop.test.js +17 -0
  26. package/dist/src/pipeline/steps/lint-fix-loop.test.js.map +1 -1
  27. package/dist/src/pipeline/steps/story-fix-loop.d.ts.map +1 -1
  28. package/dist/src/pipeline/steps/story-fix-loop.js +1 -0
  29. package/dist/src/pipeline/steps/story-fix-loop.js.map +1 -1
  30. package/dist/src/pipeline/steps/story-fix-loop.test.js +4 -0
  31. package/dist/src/pipeline/steps/story-fix-loop.test.js.map +1 -1
  32. package/dist/src/pipeline/steps/storybook-test.test.js +4 -0
  33. package/dist/src/pipeline/steps/storybook-test.test.js.map +1 -1
  34. package/dist/src/pipeline/steps/test-fix-loop.d.ts.map +1 -1
  35. package/dist/src/pipeline/steps/test-fix-loop.js +1 -0
  36. package/dist/src/pipeline/steps/test-fix-loop.js.map +1 -1
  37. package/dist/src/pipeline/steps/test-fix-loop.test.js +4 -0
  38. package/dist/src/pipeline/steps/test-fix-loop.test.js.map +1 -1
  39. package/dist/src/pipeline/steps/type-fix-loop.d.ts.map +1 -1
  40. package/dist/src/pipeline/steps/type-fix-loop.js +1 -0
  41. package/dist/src/pipeline/steps/type-fix-loop.js.map +1 -1
  42. package/dist/src/pipeline/steps/type-fix-loop.test.js +4 -0
  43. package/dist/src/pipeline/steps/type-fix-loop.test.js.map +1 -1
  44. package/dist/src/prompt.d.ts +3 -3
  45. package/dist/src/prompt.d.ts.map +1 -1
  46. package/dist/src/prompt.js +29 -9
  47. package/dist/src/prompt.js.map +1 -1
  48. package/dist/src/prompt.test.js +143 -0
  49. package/dist/src/prompt.test.js.map +1 -1
  50. package/dist/src/tools/lint-runner.d.ts.map +1 -1
  51. package/dist/src/tools/lint-runner.js +9 -4
  52. package/dist/src/tools/lint-runner.js.map +1 -1
  53. package/dist/src/tools/lint-runner.test.js +3 -7
  54. package/dist/src/tools/lint-runner.test.js.map +1 -1
  55. package/dist/src/tools/test-runner.js +8 -4
  56. package/dist/src/tools/test-runner.js.map +1 -1
  57. package/dist/src/tools/test-runner.test.js +49 -3
  58. package/dist/src/tools/test-runner.test.js.map +1 -1
  59. package/dist/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +3 -3
  61. package/scripts/generate-all.ts +673 -0
  62. package/src/commands/implement-component.test.ts +52 -0
  63. package/src/commands/implement-component.ts +4 -0
  64. package/src/pipeline/run-pipeline.test.ts +39 -3
  65. package/src/pipeline/run-pipeline.ts +16 -0
  66. package/src/pipeline/steps/generate-component.test.ts +4 -0
  67. package/src/pipeline/steps/generate-story.test.ts +4 -0
  68. package/src/pipeline/steps/generate-test.test.ts +4 -0
  69. package/src/pipeline/steps/lint-fix-loop.test.ts +21 -0
  70. package/src/pipeline/steps/lint-fix-loop.ts +5 -3
  71. package/src/pipeline/steps/story-fix-loop.test.ts +4 -0
  72. package/src/pipeline/steps/story-fix-loop.ts +1 -0
  73. package/src/pipeline/steps/storybook-test.test.ts +4 -0
  74. package/src/pipeline/steps/test-fix-loop.test.ts +4 -0
  75. package/src/pipeline/steps/test-fix-loop.ts +1 -0
  76. package/src/pipeline/steps/type-fix-loop.test.ts +4 -0
  77. package/src/pipeline/steps/type-fix-loop.ts +1 -0
  78. package/src/prompt.test.ts +176 -0
  79. package/src/prompt.ts +29 -9
  80. package/src/tools/lint-runner.test.ts +6 -7
  81. package/src/tools/lint-runner.ts +10 -4
  82. package/src/tools/test-runner.test.ts +55 -3
  83. package/src/tools/test-runner.ts +8 -4
package/src/prompt.ts CHANGED
@@ -90,7 +90,12 @@ RULES
90
90
  10. **Import paths.** Import composed UI components using the exact paths shown in the **Composed Components** section of the project context. Import utility functions from \`@/lib/utils\` (e.g. \`import { cn } from '@/lib/utils'\`). NEVER use made-up package names like \`'your-design-system'\`, \`'@company/ui'\`, or \`'@acme/components'\`. NEVER guess import paths — only use paths explicitly provided in the prompt.
91
91
  11. **Code only.** Return ONLY the component file code. No markdown fences, no commentary, no file names.
92
92
  12. **Numeric formatting.** Use the \`tabular-nums\` Tailwind class on all numeric displays (prices, counts, statistics). Use \`Intl.NumberFormat\` for currency formatting rather than string template literals with \`$\`.
93
- 13. **Type imports for composed component props.** When you need to reference a composed component's prop type (e.g. to type an array of items), use \`import type { XProps } from '...'\` with the exact import path from the Composed Components section. Never reference a type without importing it.`;
93
+ 13. **Type imports for composed component props.** When you need to reference a composed component's prop type (e.g. to type an array of items), use \`import type { XProps } from '...'\` with the exact import path from the Composed Components section. Never reference a type without importing it.
94
+ 14. **Strict package boundary.** You may ONLY import from packages listed in the project's \`package.json\` dependencies/devDependencies and from local \`@/\` paths shown in the project context. Do NOT import from any other npm package — even well-known ones. If the spec implies behavior that would normally require an external package (routing, data fetching, animations, icons), implement it with plain React, callback props, and CSS/Tailwind instead.
95
+ 15. **UI components from barrel only.** Import UI components ONLY from \`@/components/ui\` using the exact export names shown in the barrel file (\`src/components/ui/index.ts\`). Do NOT import UI components that aren't listed in the barrel. Do NOT guess component names — if a UI component isn't in the barrel, build the equivalent with plain HTML + Tailwind.
96
+ 16. **Page components use callback props for navigation.** Never import a router library. Implement navigation via callback props (e.g. \`onNavigate?: (path: string) => void\`) and render \`<a>\` tags or \`<button>\` elements that call the callback. The project does not include a router.
97
+ 17. **Controlled input initialization.** When using \`useState\` for form input values, always initialize text/number inputs with an empty string (\`useState('')\`), never \`undefined\` or \`null\`. Inputs with \`value={undefined}\` become uncontrolled and return \`null\` from \`.value\`, which breaks \`toHaveValue('')\` assertions.
98
+ 18. **Array keys without indices.** Never use the \`.map()\` callback index parameter as a React key — not directly (\`key={i}\`) nor inside a template literal (\`key={\\\`item-\\\${i}\\\`}\`). Biome's \`noArrayIndexKey\` rule flags both forms. For data items, use a unique property (\`key={item.id}\`). For generated placeholders (skeletons, spacers), pre-compute an ID array outside the JSX: \`const ids = Array.from({ length: count }, (_, i) => \\\`placeholder-\\\${i}\\\`)\`, then \`ids.map(id => <div key={id}>…</div>)\`.`;
94
99
 
95
100
  const COMPONENT_CHECKLIST = `─────────────────────────────────────────────
96
101
  QUALITY CHECKLIST
@@ -108,7 +113,9 @@ Before returning, verify:
108
113
  - [ ] Existing component behavior is preserved when modifying
109
114
  - [ ] Numeric values use tabular-nums and Intl.NumberFormat for currency
110
115
  - [ ] forwardRef uses \`export const X = React.forwardRef(...)\` syntax, never \`export function X = forwardRef(...)\`
111
- - [ ] All referenced types from composed components are imported with \`import type { ... }\``;
116
+ - [ ] All referenced types from composed components are imported with \`import type { ... }\`
117
+ - [ ] Form input \`useState\` calls are initialized with \`''\`, not \`undefined\` or \`null\`
118
+ - [ ] No \`.map()\` callback index parameter used as a React key (directly or in template literals)`;
112
119
 
113
120
  export const componentPromptSections = {
114
121
  PREAMBLE: COMPONENT_PREAMBLE,
@@ -205,7 +212,13 @@ RULES
205
212
  9. **Test descriptions mirror specs.** Use spec language directly in test descriptions so traceability is obvious.
206
213
  10. **Code only.** Return ONLY the test file code. No markdown fences, no commentary, no file names.
207
214
  11. **Tailwind classes, not inline styles.** The project uses Tailwind CSS. In JSDOM, Tailwind utility classes are just class names — they do NOT produce computed inline styles. Use \`toHaveClass('class-name')\` for class-based assertions. NEVER use \`toHaveStyle()\` for Tailwind utilities like \`tabular-nums\`, \`touch-manipulation\`, \`min-w-[44px]\`, etc. For hover states, transforms, transitions, and pseudo-elements — JSDOM cannot compute these. Skip those assertions entirely.
208
- 12. **Verify semantic elements from component source.** Some UI library components use unexpected elements (e.g., CardTitle renders a \`<div>\`, not a heading). If composed component source code is provided, check the actual rendered element before choosing your query strategy. Prefer \`getByText\` when the semantic element is uncertain.`;
215
+ 12. **Verify semantic elements from component source.** Some UI library components use unexpected elements (e.g., CardTitle renders a \`<div>\`, not a heading). If composed component source code is provided, check the actual rendered element before choosing your query strategy. Prefer \`getByText\` when the semantic element is uncertain.
216
+ 13. **Strict package boundary.** Only import from \`vitest\`, \`@testing-library/react\`, \`@testing-library/user-event\`, and the component under test (via \`./ComponentName\`). Never import \`@testing-library/dom\` or any other \`@testing-library/*\` sub-package. Do NOT import any other npm package. Do NOT import other components (siblings, composed components, UI components) — only import the one component you are testing.
217
+ 14. **jsdom limitations.** Tests run in jsdom, which does NOT support: \`DataTransfer\`, \`ClipboardEvent\`, \`IntersectionObserver\`, \`ResizeObserver\`, \`window.matchMedia\`, \`getBoundingClientRect\` returning real dimensions, CSS computed styles, \`scrollIntoView\`, or HTML5 drag-and-drop events (\`dragstart\`, \`drop\`). Do NOT write tests that depend on these APIs. For drag-and-drop specs, test the callbacks directly (e.g. call \`onDrop\` with mock data) instead of simulating drag events. For scroll-to-bottom specs, test that the scroll container ref exists rather than asserting \`scrollIntoView\` was called. For specs requiring unavailable APIs, test the closest observable behavior that jsdom supports.
218
+ 15. **Test Tailwind classes individually.** When asserting CSS classes, use separate \`toHaveClass\` calls for each important class: \`expect(el).toHaveClass('rounded-md')\`, \`expect(el).toHaveClass('shadow-sm')\`. Do NOT assert the entire class string as one value — the component may include additional utility classes. Focus on the classes that the spec explicitly mentions.
219
+ 16. **Mock composed components.** If the component under test composes other project components (not built-in HTML elements), mock them with \`vi.mock\` so tests are isolated. Mock pattern: \`vi.mock('./ChildComponent', () => ({ ChildComponent: ({ children, ...rest }: Record<string, unknown>) => <div data-testid="child-component">{children as React.ReactNode}</div> }))\`. Do NOT spread props (\`{...props}\`) onto the mock div — callback functions are not valid DOM attributes. When testing a page or container that mocks its children, only test the page's own rendering (loading text, error messages, layout containers). Do NOT query for interactive elements (buttons, inputs) that only exist inside the real (unmocked) children. When asserting props passed to a mocked component, NEVER use \`toHaveBeenCalledWith\` — React passes \`(props, ref)\` where \`ref\` is \`undefined\` for non-forwardRef components, and \`expect.anything()\` does NOT match \`undefined\`. Instead, access mock calls directly: \`const [props] = MockChild.mock.calls[0]; expect(props).toMatchObject({...});\`. This is mandatory for ALL mock component prop assertions.
220
+ 17. **vi.mock factory hoisting.** \`vi.mock\` calls are hoisted to the top of the file by vitest. This means the factory function cannot reference any variables declared in the test file scope (imports, constants, etc.). Keep mock factories self-contained — define all needed values inline within the factory. If you need to spy on the mock, import it after the \`vi.mock\` call.
221
+ 18. **Semantic HTML role mapping.** When querying elements with \`getByRole\`, use the correct ARIA role, not the HTML tag name. Common mappings: \`<section>\` has role="region" (only when it has an accessible name via \`aria-label\` or \`aria-labelledby\` — otherwise it has NO implicit role and you must use a different query like \`getByTestId\`). \`<article>\` → \`"article"\`, \`<nav>\` → \`"navigation"\`, \`<aside>\` → \`"complementary"\`, \`<header>\` → \`"banner"\`, \`<footer>\` → \`"contentinfo"\`, \`<main>\` → \`"main"\`. There is NO role called \`"section"\` — never use \`getByRole('section')\`.`;
209
222
 
210
223
  const TEST_CHECKLIST = `─────────────────────────────────────────────
211
224
  QUALITY CHECKLIST
@@ -558,7 +571,7 @@ export function buildComponentPrompt(input: ComponentPromptInput): PromptResult
558
571
  }
559
572
 
560
573
  if (input.projectSection) {
561
- parts.push('\n\n' + input.projectSection);
574
+ parts.push(`\n\n${input.projectSection}`);
562
575
  }
563
576
 
564
577
  return {
@@ -583,7 +596,7 @@ export function buildTestPrompt(input: TestPromptInput): PromptResult {
583
596
  }
584
597
 
585
598
  if (input.projectSection) {
586
- parts.push('\n\n' + input.projectSection);
599
+ parts.push(`\n\n${input.projectSection}`);
587
600
  }
588
601
 
589
602
  return {
@@ -613,7 +626,7 @@ export function buildStoryPrompt(input: StoryPromptInput): PromptResult {
613
626
  }
614
627
 
615
628
  if (input.projectSection) {
616
- parts.push('\n\n' + input.projectSection);
629
+ parts.push(`\n\n${input.projectSection}`);
617
630
  }
618
631
 
619
632
  return {
@@ -653,7 +666,7 @@ export function buildReconcilerPrompt(input: ReconcilerPromptInput): PromptResul
653
666
  parts.push('```');
654
667
 
655
668
  if (input.projectSection) {
656
- parts.push('\n\n' + input.projectSection);
669
+ parts.push(`\n\n${input.projectSection}`);
657
670
  }
658
671
 
659
672
  return {
@@ -725,7 +738,13 @@ Rules:
725
738
  - Only modify the test if it has a genuine bug (e.g. wrong import path, typo in query). Never weaken assertions.
726
739
  - Make minimal changes to pass the failing tests without breaking passing ones.
727
740
  - Return complete files, not diffs.
728
- - No commentary, no explanations. Just the two code blocks.`;
741
+ - No commentary, no explanations. Just the two code blocks.
742
+ - **Unresolvable imports.** If the error is "Failed to resolve import" for a package (e.g. react-router-dom, lucide-react, @tanstack/react-query), that package is NOT installed. Fix both the component and test by removing the unavailable import entirely and rewriting the code to work without it. Replace router hooks with callback props, replace icon components with inline \`<span>\` or \`<svg>\` elements, replace data-fetching hooks with props.
743
+ - **Missing UI component imports.** If the error is "Failed to resolve import" for a \`@/components/ui/*\` path, that UI component does not exist in the project. Remove the import and replace usage with plain HTML elements + Tailwind classes that provide equivalent functionality.
744
+ - **vi.mock hoisting errors.** If the error mentions "vi.mock" and "hoisted to top of the file", the mock factory references variables from the file scope. Fix by inlining all values within the factory function — do not reference imports, constants, or variables declared outside the factory.
745
+ - **Mock spy argument count mismatch.** If a \`toHaveBeenCalledWith\` assertion on a mocked component fails because of an extra \`undefined\` argument, React is passing \`(props, ref)\` where \`ref\` is \`undefined\`. \`expect.anything()\` does NOT match \`undefined\`, so do NOT use it. Fix the TEST: replace \`toHaveBeenCalledWith\` with direct mock call access: \`const [props] = MockedComponent.mock.calls[0]; expect(props).toMatchObject({...});\`.
746
+ - **toHaveStyle() failures.** jsdom does NOT compute CSS from Tailwind classes. If a \`toHaveStyle()\` assertion fails because the received style is empty or missing, replace \`expect(el).toHaveStyle({prop: value})\` with \`expect(el).toHaveClass('tailwind-class')\` using the corresponding Tailwind utility class. Never use \`toHaveStyle()\` in jsdom tests.
747
+ - **ReferenceError on mocked component.** If the error is \`ReferenceError: X is not defined\` and X is a component that was mocked with \`vi.mock\`, the test is accessing \`X.mock.calls\` without importing X. Add the import after the \`vi.mock\` call: \`import { X } from './X';\` — vitest resolves this to the mock.`;
729
748
 
730
749
  export interface TestFixInput {
731
750
  componentCode: string;
@@ -779,7 +798,8 @@ Rules:
779
798
  - Fix ONLY the lint errors listed. Do not refactor unrelated code.
780
799
  - Preserve all behavior and functionality.
781
800
  - Return complete files, not diffs.
782
- - No commentary, no explanations. Just the two code blocks.`;
801
+ - No commentary, no explanations. Just the two code blocks.
802
+ - **noArrayIndexKey.** If the error is \`lint/suspicious/noArrayIndexKey\`, the \`.map()\` callback index is used as a React key — either directly (\`key={i}\`) or inside a template literal (\`key={\\\`prefix-\\\${i}\\\`}\`). Both are flagged by Biome. Fix by using a unique property from the item data (\`key={item.id}\`). If no \`id\` field exists, use another unique item property (\`key={item.title}\`). For generated arrays (skeletons, placeholders) where items have no unique property, pre-compute IDs outside JSX: \`const ids = Array.from({ length: n }, (_, i) => \\\`skeleton-\\\${i}\\\`)\`, then \`ids.map(id => <div key={id}>…</div>)\` — this moves the index out of the \`.map()\` callback.`;
783
803
 
784
804
  export interface LintFixInput {
785
805
  componentCode: string;
@@ -14,7 +14,7 @@ describe('runLint', () => {
14
14
  const result = runLint(['src/Button.tsx'], '/project');
15
15
 
16
16
  expect(result).toEqual({ passed: true, errors: [] });
17
- expect(execSync).toHaveBeenCalledWith('npx biome check src/Button.tsx', {
17
+ expect(execSync).toHaveBeenCalledWith('npx biome check --config-path=/project/biome.json src/Button.tsx', {
18
18
  cwd: '/project',
19
19
  stdio: 'pipe',
20
20
  encoding: 'utf-8',
@@ -43,11 +43,10 @@ describe('runLint', () => {
43
43
 
44
44
  runLint(['src/Button.tsx', 'src/Button.test.tsx'], '/project');
45
45
 
46
- expect(execSync).toHaveBeenCalledWith('npx biome check src/Button.tsx src/Button.test.tsx', {
47
- cwd: '/project',
48
- stdio: 'pipe',
49
- encoding: 'utf-8',
50
- });
46
+ expect(execSync).toHaveBeenCalledWith(
47
+ 'npx biome check --config-path=/project/biome.json src/Button.tsx src/Button.test.tsx',
48
+ { cwd: '/project', stdio: 'pipe', encoding: 'utf-8' },
49
+ );
51
50
  });
52
51
  });
53
52
 
@@ -58,7 +57,7 @@ describe('runLintFix', () => {
58
57
  const result = runLintFix(['src/Button.tsx'], '/project');
59
58
 
60
59
  expect(result).toEqual({ passed: true, errors: [] });
61
- expect(execSync).toHaveBeenCalledWith('npx biome check --write src/Button.tsx', {
60
+ expect(execSync).toHaveBeenCalledWith('npx biome check --write --config-path=/project/biome.json src/Button.tsx', {
62
61
  cwd: '/project',
63
62
  stdio: 'pipe',
64
63
  encoding: 'utf-8',
@@ -1,13 +1,19 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import path from 'node:path';
2
3
 
3
4
  export type LintResult = {
4
5
  passed: boolean;
5
6
  errors: string[];
6
7
  };
7
8
 
9
+ function biomeCmd(action: string, filePaths: string[], targetDir: string): string {
10
+ const configPath = path.resolve(targetDir, 'biome.json');
11
+ return `npx biome ${action} --config-path=${configPath} ${filePaths.join(' ')}`;
12
+ }
13
+
8
14
  export function runLint(filePaths: string[], targetDir: string): LintResult {
9
15
  try {
10
- execSync(`npx biome check ${filePaths.join(' ')}`, {
16
+ execSync(biomeCmd('check', filePaths, targetDir), {
11
17
  cwd: targetDir,
12
18
  stdio: 'pipe',
13
19
  encoding: 'utf-8',
@@ -20,7 +26,7 @@ export function runLint(filePaths: string[], targetDir: string): LintResult {
20
26
  if ('stdout' in err && typeof err.stdout === 'string') stdout = err.stdout;
21
27
  if ('stderr' in err && typeof err.stderr === 'string') stderr = err.stderr;
22
28
  }
23
- const combined = stdout + '\n' + stderr;
29
+ const combined = `${stdout}\n${stderr}`;
24
30
 
25
31
  const errors = combined.split('\n').filter((line) => line.trim().length > 0);
26
32
 
@@ -30,7 +36,7 @@ export function runLint(filePaths: string[], targetDir: string): LintResult {
30
36
 
31
37
  export function runLintFix(filePaths: string[], targetDir: string): LintResult {
32
38
  try {
33
- execSync(`npx biome check --write ${filePaths.join(' ')}`, {
39
+ execSync(biomeCmd('check --write', filePaths, targetDir), {
34
40
  cwd: targetDir,
35
41
  stdio: 'pipe',
36
42
  encoding: 'utf-8',
@@ -43,7 +49,7 @@ export function runLintFix(filePaths: string[], targetDir: string): LintResult {
43
49
  if ('stdout' in err && typeof err.stdout === 'string') stdout = err.stdout;
44
50
  if ('stderr' in err && typeof err.stderr === 'string') stderr = err.stderr;
45
51
  }
46
- const combined = stdout + '\n' + stderr;
52
+ const combined = `${stdout}\n${stderr}`;
47
53
 
48
54
  const errors = combined.split('\n').filter((line) => line.trim().length > 0);
49
55
 
@@ -102,13 +102,13 @@ describe('runTests', () => {
102
102
  });
103
103
  });
104
104
 
105
- it('truncates output to 2000 chars', () => {
106
- const longOutput = JSON.stringify({ testResults: [] }) + 'x'.repeat(3000);
105
+ it('truncates output to 4000 chars', () => {
106
+ const longOutput = JSON.stringify({ testResults: [] }) + 'x'.repeat(5000);
107
107
  vi.mocked(execSync).mockReturnValue(longOutput);
108
108
 
109
109
  const result = runTests('src/Button.test.tsx', '/project');
110
110
 
111
- expect(result.output.length).toBeLessThanOrEqual(2000);
111
+ expect(result.output.length).toBeLessThanOrEqual(4000);
112
112
  });
113
113
 
114
114
  it('handles error object without stdout or stderr properties', () => {
@@ -197,6 +197,58 @@ describe('runTests', () => {
197
197
  });
198
198
  });
199
199
 
200
+ it('captures suite-level error when assertionResults is empty but suite failed', () => {
201
+ const json = JSON.stringify({
202
+ testResults: [
203
+ {
204
+ status: 'failed',
205
+ message: 'Failed to collect tests: Cannot find module @testing-library/jest-dom/vitest',
206
+ assertionResults: [],
207
+ },
208
+ ],
209
+ });
210
+ vi.mocked(execSync).mockReturnValue(json);
211
+
212
+ const result = runTests('src/Button.test.tsx', '/project');
213
+
214
+ expect(result).toEqual({
215
+ passed: false,
216
+ numPassed: 0,
217
+ numFailed: 1,
218
+ failures: ['Suite error: Failed to collect tests: Cannot find module @testing-library/jest-dom/vitest'],
219
+ output: expect.any(String),
220
+ });
221
+ });
222
+
223
+ it('does not capture suite-level error when assertionResults already has failures', () => {
224
+ const json = JSON.stringify({
225
+ testResults: [
226
+ {
227
+ status: 'failed',
228
+ message: 'Some suite message',
229
+ assertionResults: [
230
+ {
231
+ status: 'failed',
232
+ fullName: 'test one',
233
+ failureMessages: ['assertion failed'],
234
+ },
235
+ ],
236
+ },
237
+ ],
238
+ });
239
+ vi.mocked(execSync).mockReturnValue(json);
240
+
241
+ const result = runTests('src/Button.test.tsx', '/project');
242
+
243
+ expect(result).toEqual({
244
+ passed: false,
245
+ numPassed: 0,
246
+ numFailed: 1,
247
+ failures: ['test one: assertion failed'],
248
+ output: expect.any(String),
249
+ });
250
+ });
251
+
200
252
  it('handles malformed JSON after opening brace', () => {
201
253
  vi.mocked(execSync).mockReturnValue('prefix {invalid json');
202
254
 
@@ -25,7 +25,7 @@ export function runTests(testFilePath: string, targetDir: string): TestRunResult
25
25
  if ('stdout' in err && typeof err.stdout === 'string') stdout = err.stdout;
26
26
  if ('stderr' in err && typeof err.stderr === 'string') stderr = err.stderr;
27
27
  }
28
- const combined = stdout + '\n' + stderr;
28
+ const combined = `${stdout}\n${stderr}`;
29
29
 
30
30
  const parsed = parseVitestJson(combined);
31
31
  return parsed;
@@ -40,7 +40,7 @@ function parseVitestJson(output: string): TestRunResult {
40
40
  numPassed: 0,
41
41
  numFailed: 0,
42
42
  failures: [],
43
- output: output.slice(0, 2000),
43
+ output: output.slice(0, 4000),
44
44
  };
45
45
  }
46
46
 
@@ -63,6 +63,10 @@ function parseVitestJson(output: string): TestRunResult {
63
63
  failures.push(`${name}: ${message}`);
64
64
  }
65
65
  }
66
+ if (suite.status === 'failed' && suite.message && failures.length === 0) {
67
+ failures.push(`Suite error: ${suite.message}`);
68
+ numFailed++;
69
+ }
66
70
  }
67
71
 
68
72
  return {
@@ -70,7 +74,7 @@ function parseVitestJson(output: string): TestRunResult {
70
74
  numPassed,
71
75
  numFailed,
72
76
  failures,
73
- output: output.slice(0, 2000),
77
+ output: output.slice(0, 4000),
74
78
  };
75
79
  } catch {
76
80
  return {
@@ -78,7 +82,7 @@ function parseVitestJson(output: string): TestRunResult {
78
82
  numPassed: 0,
79
83
  numFailed: 0,
80
84
  failures: [],
81
- output: output.slice(0, 2000),
85
+ output: output.slice(0, 4000),
82
86
  };
83
87
  }
84
88
  }