Generated by Spark: Designers for Playwright, Storybook and unit testing.

This commit is contained in:
2026-01-16 01:03:13 +00:00
committed by GitHub
parent 2ef3af4bf2
commit 217e9718bb
7 changed files with 1327 additions and 4 deletions

29
PRD.md
View File

@@ -47,6 +47,27 @@ This is a full-featured low-code IDE with multiple integrated tools (code editor
- **Progression**: Select or create theme variant → Adjust standard colors or add custom colors → Configure typography and spacing → Preview updates live across all variants → Switch between variants → Export theme configuration with all variants
- **Success criteria**: Color pickers work; custom colors can be added/removed; multiple theme variants persist; AI themes match descriptions and have good contrast; generates valid MUI theme code for all variants including light and dark modes
### Playwright E2E Test Designer
- **Functionality**: Visual designer for Playwright end-to-end tests with step-by-step action configuration and AI-powered test generation
- **Purpose**: Create comprehensive E2E tests without writing Playwright code manually
- **Trigger**: Opening the Playwright tab
- **Progression**: Create test suite manually or with AI → Add test steps (navigate, click, fill, expect) → Configure selectors and assertions → AI can generate complete test flows → Export Playwright test files
- **Success criteria**: Can create tests with multiple steps; selectors and assertions are properly configured; AI-generated tests are executable; generates valid Playwright test code
### Storybook Story Designer
- **Functionality**: Visual designer for Storybook stories with component args/props configuration and AI story generation
- **Purpose**: Document component variations and states without manually writing story files
- **Trigger**: Opening the Storybook tab
- **Progression**: Create story manually or with AI → Configure component name and story name → Add args/props → Organize by category → AI can suggest appropriate variations → Export Storybook story files
- **Success criteria**: Can create stories for any component; args can be added/edited with type detection; organized by categories; AI stories showcase meaningful variations; generates valid Storybook CSF3 format
### Unit Test Designer
- **Functionality**: Visual designer for unit tests with test case and assertion management, supports component/function/hook/integration tests, and AI test generation
- **Purpose**: Create comprehensive test suites with proper setup, assertions, and teardown without writing test code manually
- **Trigger**: Opening the Unit Tests tab
- **Progression**: Create test suite manually or with AI → Select test type (component/function/hook/integration) → Add test cases → Configure setup, assertions, and teardown → AI can generate complete test suites → Export test files for Vitest/React Testing Library
- **Success criteria**: Can create test suites for different types; test cases have multiple assertions; setup/teardown code is optional; AI tests are comprehensive; generates valid Vitest test code
### Project Generator
- **Functionality**: Exports complete Next.js project with all configurations
- **Purpose**: Converts visual designs into downloadable, runnable applications
@@ -63,6 +84,10 @@ This is a full-featured low-code IDE with multiple integrated tools (code editor
- **AI Generation Failures**: Provide clear error messages and fallback to manual editing when AI requests fail
- **Rate Limiting**: Handle OpenAI API rate limits gracefully with user-friendly messages
- **Invalid AI Responses**: Validate and sanitize AI-generated code before insertion
- **Empty Test Suites**: Show helpful empty states with guidance for creating first test/story
- **Invalid Test Selectors**: Warn when Playwright selectors might be problematic
- **Missing Test Assertions**: Highlight test cases without assertions as incomplete
- **Storybook Args Type Mismatch**: Auto-detect arg types and provide appropriate input controls
## Design Direction
The design should evoke a professional IDE environment while remaining approachable - think Visual Studio Code meets Figma. Clean panels, clear hierarchy, and purposeful use of space to avoid overwhelming users with options.
@@ -121,9 +146,13 @@ Animations should feel responsive and purposeful - quick panel transitions (200m
- Database (database icon) for models
- Tree (tree-structure icon) for components
- PaintBrush (paint-brush icon) for styling
- Play (play icon) for Playwright E2E tests
- BookOpen (book-open icon) for Storybook stories
- Flask (flask icon) for unit tests
- FileCode (file-code icon) for individual files
- Plus (plus icon) for create actions
- Download (download icon) for export
- Sparkle (sparkle icon) for AI generation
- **Spacing**:
- Panel padding: p-6 (24px) for main content areas

View File

@@ -4,14 +4,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { Code, Database, Tree, PaintBrush, Download, Sparkle } from '@phosphor-icons/react'
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig } from '@/types/project'
import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play } from '@phosphor-icons/react'
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest } from '@/types/project'
import { CodeEditor } from '@/components/CodeEditor'
import { ModelDesigner } from '@/components/ModelDesigner'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { StyleDesigner } from '@/components/StyleDesigner'
import { FileExplorer } from '@/components/FileExplorer'
import { generateNextJSProject, generatePrismaSchema, generateMUITheme } from '@/lib/generators'
import { PlaywrightDesigner } from '@/components/PlaywrightDesigner'
import { StorybookDesigner } from '@/components/StorybookDesigner'
import { UnitTestDesigner } from '@/components/UnitTestDesigner'
import { generateNextJSProject, generatePrismaSchema, generateMUITheme, generatePlaywrightTests, generateStorybookStories, generateUnitTests } from '@/lib/generators'
import { AIService } from '@/lib/ai-service'
import { toast } from 'sonner'
import {
@@ -90,6 +93,9 @@ function App() {
const [models, setModels] = useKV<PrismaModel[]>('project-models', [])
const [components, setComponents] = useKV<ComponentNode[]>('project-components', [])
const [theme, setTheme] = useKV<ThemeConfig>('project-theme', DEFAULT_THEME)
const [playwrightTests, setPlaywrightTests] = useKV<PlaywrightTest[]>('project-playwright-tests', [])
const [storybookStories, setStorybookStories] = useKV<StorybookStory[]>('project-storybook-stories', [])
const [unitTests, setUnitTests] = useKV<UnitTest[]>('project-unit-tests', [])
const [activeFileId, setActiveFileId] = useState<string | null>((files || [])[0]?.id || null)
const [activeTab, setActiveTab] = useState('code')
const [exportDialogOpen, setExportDialogOpen] = useState(false)
@@ -99,6 +105,9 @@ function App() {
const safeModels = models || []
const safeComponents = components || []
const safeTheme = theme || DEFAULT_THEME
const safePlaywrightTests = playwrightTests || []
const safeStorybookStories = storybookStories || []
const safeUnitTests = unitTests || []
const handleFileChange = (fileId: string, content: string) => {
setFiles((currentFiles) =>
@@ -124,11 +133,17 @@ function App() {
const prismaSchema = generatePrismaSchema(safeModels)
const themeCode = generateMUITheme(safeTheme)
const playwrightTestCode = generatePlaywrightTests(safePlaywrightTests)
const storybookFiles = generateStorybookStories(safeStorybookStories)
const unitTestFiles = generateUnitTests(safeUnitTests)
const allFiles = {
...projectFiles,
'prisma/schema.prisma': prismaSchema,
'src/theme.ts': themeCode,
'e2e/tests.spec.ts': playwrightTestCode,
...storybookFiles,
...unitTestFiles,
...safeFiles.reduce((acc, file) => {
acc[file.path] = file.content
return acc
@@ -216,6 +231,18 @@ function App() {
<PaintBrush size={18} />
Styling
</TabsTrigger>
<TabsTrigger value="playwright" className="gap-2">
<Play size={18} />
Playwright
</TabsTrigger>
<TabsTrigger value="storybook" className="gap-2">
<BookOpen size={18} />
Storybook
</TabsTrigger>
<TabsTrigger value="unit-tests" className="gap-2">
<Flask size={18} />
Unit Tests
</TabsTrigger>
</TabsList>
</div>
@@ -257,6 +284,18 @@ function App() {
<TabsContent value="styling" className="h-full m-0">
<StyleDesigner theme={safeTheme} onThemeChange={setTheme} />
</TabsContent>
<TabsContent value="playwright" className="h-full m-0">
<PlaywrightDesigner tests={safePlaywrightTests} onTestsChange={setPlaywrightTests} />
</TabsContent>
<TabsContent value="storybook" className="h-full m-0">
<StorybookDesigner stories={safeStorybookStories} onStoriesChange={setStorybookStories} />
</TabsContent>
<TabsContent value="unit-tests" className="h-full m-0">
<UnitTestDesigner tests={safeUnitTests} onTestsChange={setUnitTests} />
</TabsContent>
</div>
</Tabs>

View File

@@ -0,0 +1,335 @@
import { useState } from 'react'
import { PlaywrightTest, PlaywrightStep } from '@/types/project'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Plus, Trash, Play, Sparkle } from '@phosphor-icons/react'
import { toast } from 'sonner'
interface PlaywrightDesignerProps {
tests: PlaywrightTest[]
onTestsChange: (tests: PlaywrightTest[]) => void
}
export function PlaywrightDesigner({ tests, onTestsChange }: PlaywrightDesignerProps) {
const [selectedTestId, setSelectedTestId] = useState<string | null>(tests[0]?.id || null)
const selectedTest = tests.find(t => t.id === selectedTestId)
const handleAddTest = () => {
const newTest: PlaywrightTest = {
id: `test-${Date.now()}`,
name: 'New Test',
description: '',
pageUrl: '/',
steps: []
}
onTestsChange([...tests, newTest])
setSelectedTestId(newTest.id)
}
const handleDeleteTest = (testId: string) => {
onTestsChange(tests.filter(t => t.id !== testId))
if (selectedTestId === testId) {
const remaining = tests.filter(t => t.id !== testId)
setSelectedTestId(remaining[0]?.id || null)
}
}
const handleUpdateTest = (testId: string, updates: Partial<PlaywrightTest>) => {
onTestsChange(
tests.map(t => t.id === testId ? { ...t, ...updates } : t)
)
}
const handleAddStep = () => {
if (!selectedTest) return
const newStep: PlaywrightStep = {
id: `step-${Date.now()}`,
action: 'click',
selector: '',
value: ''
}
handleUpdateTest(selectedTest.id, {
steps: [...selectedTest.steps, newStep]
})
}
const handleUpdateStep = (stepId: string, updates: Partial<PlaywrightStep>) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
steps: selectedTest.steps.map(s => s.id === stepId ? { ...s, ...updates } : s)
})
}
const handleDeleteStep = (stepId: string) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
steps: selectedTest.steps.filter(s => s.id !== stepId)
})
}
const handleGenerateWithAI = async () => {
const description = prompt('Describe the E2E test you want to generate:')
if (!description) return
try {
toast.info('Generating test with AI...')
const promptText = `You are a Playwright test generator. Create an E2E test based on: "${description}"
Return a valid JSON object with a single property "test":
{
"test": {
"id": "unique-id",
"name": "Test Name",
"description": "What this test does",
"pageUrl": "/path",
"steps": [
{
"id": "step-id",
"action": "navigate" | "click" | "fill" | "expect" | "wait" | "select" | "check" | "uncheck",
"selector": "css selector or text",
"value": "value for fill/select actions",
"assertion": "expected value for expect action",
"timeout": 5000
}
]
}
}
Create a complete test flow with appropriate selectors and assertions.`
const response = await window.spark.llm(promptText, 'gpt-4o', true)
const parsed = JSON.parse(response)
onTestsChange([...tests, parsed.test])
setSelectedTestId(parsed.test.id)
toast.success('Test generated successfully!')
} catch (error) {
console.error(error)
toast.error('Failed to generate test')
}
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-sm">E2E Tests</h2>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={handleGenerateWithAI}>
<Sparkle size={14} weight="duotone" />
</Button>
<Button size="sm" onClick={handleAddTest}>
<Plus size={14} />
</Button>
</div>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="p-2 space-y-1">
{tests.map(test => (
<div
key={test.id}
className={`p-3 rounded-md cursor-pointer flex items-start justify-between group ${
selectedTestId === test.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
}`}
onClick={() => setSelectedTestId(test.id)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{test.name}</div>
<div className="text-xs text-muted-foreground truncate">{test.pageUrl}</div>
<div className="text-xs text-muted-foreground">{test.steps.length} steps</div>
</div>
<Button
size="sm"
variant="ghost"
className="opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
handleDeleteTest(test.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
{tests.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No tests yet. Click + to create one.
</div>
)}
</div>
</ScrollArea>
</div>
<div className="flex-1 p-6">
{selectedTest ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Test Configuration</h2>
<Button variant="outline">
<Play size={16} className="mr-2" weight="fill" />
Run Test
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Test Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-name">Test Name</Label>
<Input
id="test-name"
value={selectedTest.name}
onChange={e => handleUpdateTest(selectedTest.id, { name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-description">Description</Label>
<Textarea
id="test-description"
value={selectedTest.description}
onChange={e => handleUpdateTest(selectedTest.id, { description: e.target.value })}
placeholder="What does this test verify?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-url">Page URL</Label>
<Input
id="test-url"
value={selectedTest.pageUrl}
onChange={e => handleUpdateTest(selectedTest.id, { pageUrl: e.target.value })}
placeholder="/login"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Test Steps</CardTitle>
<CardDescription>Define the actions for this test</CardDescription>
</div>
<Button size="sm" onClick={handleAddStep}>
<Plus size={14} className="mr-1" />
Add Step
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
<div className="space-y-4">
{selectedTest.steps.map((step, index) => (
<Card key={step.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Step {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteStep(step.id)}
>
<Trash size={14} />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Action</Label>
<Select
value={step.action}
onValueChange={(value: any) => handleUpdateStep(step.id, { action: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="navigate">Navigate</SelectItem>
<SelectItem value="click">Click</SelectItem>
<SelectItem value="fill">Fill</SelectItem>
<SelectItem value="expect">Expect</SelectItem>
<SelectItem value="wait">Wait</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="uncheck">Uncheck</SelectItem>
</SelectContent>
</Select>
</div>
{step.action !== 'navigate' && step.action !== 'wait' && (
<div className="space-y-2">
<Label>Selector</Label>
<Input
value={step.selector || ''}
onChange={e => handleUpdateStep(step.id, { selector: e.target.value })}
placeholder="button, #login, [data-testid='submit']"
/>
</div>
)}
{(step.action === 'fill' || step.action === 'select') && (
<div className="space-y-2 col-span-2">
<Label>Value</Label>
<Input
value={step.value || ''}
onChange={e => handleUpdateStep(step.id, { value: e.target.value })}
placeholder="Text to enter"
/>
</div>
)}
{step.action === 'expect' && (
<div className="space-y-2 col-span-2">
<Label>Assertion</Label>
<Input
value={step.assertion || ''}
onChange={e => handleUpdateStep(step.id, { assertion: e.target.value })}
placeholder="toBeVisible(), toHaveText('...')"
/>
</div>
)}
{step.action === 'wait' && (
<div className="space-y-2">
<Label>Timeout (ms)</Label>
<Input
type="number"
value={step.timeout || 1000}
onChange={e => handleUpdateStep(step.id, { timeout: parseInt(e.target.value) })}
/>
</div>
)}
</div>
</CardContent>
</Card>
))}
{selectedTest.steps.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No steps yet. Click "Add Step" to create test actions.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Play size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-lg font-medium mb-2">No test selected</p>
<p className="text-sm text-muted-foreground mb-4">
Create or select a test to configure
</p>
<Button onClick={handleAddTest}>
<Plus size={16} className="mr-2" />
Create Test
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,324 @@
import { useState } from 'react'
import { StorybookStory } from '@/types/project'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Plus, Trash, BookOpen, Sparkle } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
interface StorybookDesignerProps {
stories: StorybookStory[]
onStoriesChange: (stories: StorybookStory[]) => void
}
export function StorybookDesigner({ stories, onStoriesChange }: StorybookDesignerProps) {
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(stories[0]?.id || null)
const [newArgKey, setNewArgKey] = useState('')
const [newArgValue, setNewArgValue] = useState('')
const selectedStory = stories.find(s => s.id === selectedStoryId)
const categories = Array.from(new Set(stories.map(s => s.category)))
const handleAddStory = () => {
const newStory: StorybookStory = {
id: `story-${Date.now()}`,
componentName: 'Button',
storyName: 'Default',
args: {},
description: '',
category: 'Components'
}
onStoriesChange([...stories, newStory])
setSelectedStoryId(newStory.id)
}
const handleDeleteStory = (storyId: string) => {
onStoriesChange(stories.filter(s => s.id !== storyId))
if (selectedStoryId === storyId) {
const remaining = stories.filter(s => s.id !== storyId)
setSelectedStoryId(remaining[0]?.id || null)
}
}
const handleUpdateStory = (storyId: string, updates: Partial<StorybookStory>) => {
onStoriesChange(
stories.map(s => s.id === storyId ? { ...s, ...updates } : s)
)
}
const handleAddArg = () => {
if (!selectedStory || !newArgKey) return
let parsedValue: any = newArgValue
try {
parsedValue = JSON.parse(newArgValue)
} catch {
parsedValue = newArgValue
}
handleUpdateStory(selectedStory.id, {
args: { ...selectedStory.args, [newArgKey]: parsedValue }
})
setNewArgKey('')
setNewArgValue('')
}
const handleDeleteArg = (key: string) => {
if (!selectedStory) return
const { [key]: _, ...rest } = selectedStory.args
handleUpdateStory(selectedStory.id, { args: rest })
}
const handleUpdateArg = (key: string, value: string) => {
if (!selectedStory) return
let parsedValue: any = value
try {
parsedValue = JSON.parse(value)
} catch {
parsedValue = value
}
handleUpdateStory(selectedStory.id, {
args: { ...selectedStory.args, [key]: parsedValue }
})
}
const handleGenerateWithAI = async () => {
const description = prompt('Describe the component and story you want to generate:')
if (!description) return
try {
toast.info('Generating story with AI...')
const promptText = `You are a Storybook story generator. Create a story based on: "${description}"
Return a valid JSON object with a single property "story":
{
"story": {
"id": "unique-id",
"componentName": "ComponentName (e.g., Button, Card, Input)",
"storyName": "StoryName (e.g., Primary, Large, Disabled)",
"args": {
"variant": "primary",
"size": "large",
"disabled": false
},
"description": "Description of what this story demonstrates",
"category": "Components" (e.g., Components, Forms, Layout, Data Display)
}
}
Create appropriate props/args that showcase the component variation.`
const response = await window.spark.llm(promptText, 'gpt-4o', true)
const parsed = JSON.parse(response)
onStoriesChange([...stories, parsed.story])
setSelectedStoryId(parsed.story.id)
toast.success('Story generated successfully!')
} catch (error) {
console.error(error)
toast.error('Failed to generate story')
}
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-sm">Stories</h2>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={handleGenerateWithAI}>
<Sparkle size={14} weight="duotone" />
</Button>
<Button size="sm" onClick={handleAddStory}>
<Plus size={14} />
</Button>
</div>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
{categories.map(category => (
<div key={category} className="mb-4">
<div className="px-4 py-2 text-xs font-semibold text-muted-foreground uppercase">
{category}
</div>
<div className="px-2 space-y-1">
{stories.filter(s => s.category === category).map(story => (
<div
key={story.id}
className={`p-3 rounded-md cursor-pointer flex items-start justify-between group ${
selectedStoryId === story.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
}`}
onClick={() => setSelectedStoryId(story.id)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{story.componentName}</div>
<div className="text-xs text-muted-foreground truncate">{story.storyName}</div>
</div>
<Button
size="sm"
variant="ghost"
className="opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
handleDeleteStory(story.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
</div>
</div>
))}
{stories.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No stories yet. Click + to create one.
</div>
)}
</ScrollArea>
</div>
<div className="flex-1 p-6">
{selectedStory ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Story Configuration</h2>
<Button variant="outline">
<BookOpen size={16} className="mr-2" weight="fill" />
Preview Story
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Story Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="component-name">Component Name</Label>
<Input
id="component-name"
value={selectedStory.componentName}
onChange={e => handleUpdateStory(selectedStory.id, { componentName: e.target.value })}
placeholder="Button"
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-name">Story Name</Label>
<Input
id="story-name"
value={selectedStory.storyName}
onChange={e => handleUpdateStory(selectedStory.id, { storyName: e.target.value })}
placeholder="Primary"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
value={selectedStory.category}
onChange={e => handleUpdateStory(selectedStory.id, { category: e.target.value })}
placeholder="Components"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={selectedStory.description}
onChange={e => handleUpdateStory(selectedStory.id, { description: e.target.value })}
placeholder="Describe what this story demonstrates..."
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Args / Props</CardTitle>
<CardDescription>Configure component props for this story</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Arg name"
value={newArgKey}
onChange={e => setNewArgKey(e.target.value)}
/>
<Input
placeholder="Value (JSON or string)"
value={newArgValue}
onChange={e => setNewArgValue(e.target.value)}
/>
<Button onClick={handleAddArg} disabled={!newArgKey}>
<Plus size={14} />
</Button>
</div>
<ScrollArea className="h-[300px]">
<div className="space-y-2">
{Object.entries(selectedStory.args).map(([key, value]) => (
<Card key={key}>
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Label className="font-mono text-xs">{key}</Label>
<Badge variant="outline">
{typeof value}
</Badge>
</div>
<Input
value={JSON.stringify(value)}
onChange={e => handleUpdateArg(key, e.target.value)}
className="font-mono text-xs"
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteArg(key)}
>
<Trash size={14} />
</Button>
</div>
</CardContent>
</Card>
))}
{Object.keys(selectedStory.args).length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No args configured yet. Add props above to showcase component variations.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<BookOpen size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-lg font-medium mb-2">No story selected</p>
<p className="text-sm text-muted-foreground mb-4">
Create or select a story to configure
</p>
<Button onClick={handleAddStory}>
<Plus size={16} className="mr-2" />
Create Story
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,396 @@
import { useState } from 'react'
import { UnitTest, TestCase } from '@/types/project'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Plus, Trash, Flask, Sparkle } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
interface UnitTestDesignerProps {
tests: UnitTest[]
onTestsChange: (tests: UnitTest[]) => void
}
export function UnitTestDesigner({ tests, onTestsChange }: UnitTestDesignerProps) {
const [selectedTestId, setSelectedTestId] = useState<string | null>(tests[0]?.id || null)
const selectedTest = tests.find(t => t.id === selectedTestId)
const handleAddTest = () => {
const newTest: UnitTest = {
id: `unit-test-${Date.now()}`,
name: 'New Test Suite',
description: '',
testType: 'component',
targetFile: '',
testCases: []
}
onTestsChange([...tests, newTest])
setSelectedTestId(newTest.id)
}
const handleDeleteTest = (testId: string) => {
onTestsChange(tests.filter(t => t.id !== testId))
if (selectedTestId === testId) {
const remaining = tests.filter(t => t.id !== testId)
setSelectedTestId(remaining[0]?.id || null)
}
}
const handleUpdateTest = (testId: string, updates: Partial<UnitTest>) => {
onTestsChange(
tests.map(t => t.id === testId ? { ...t, ...updates } : t)
)
}
const handleAddTestCase = () => {
if (!selectedTest) return
const newCase: TestCase = {
id: `case-${Date.now()}`,
description: 'should work correctly',
assertions: ['expect(...).toBe(...)'],
setup: '',
teardown: ''
}
handleUpdateTest(selectedTest.id, {
testCases: [...selectedTest.testCases, newCase]
})
}
const handleUpdateTestCase = (caseId: string, updates: Partial<TestCase>) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
testCases: selectedTest.testCases.map(c => c.id === caseId ? { ...c, ...updates } : c)
})
}
const handleDeleteTestCase = (caseId: string) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
testCases: selectedTest.testCases.filter(c => c.id !== caseId)
})
}
const handleAddAssertion = (caseId: string) => {
if (!selectedTest) return
const testCase = selectedTest.testCases.find(c => c.id === caseId)
if (!testCase) return
handleUpdateTestCase(caseId, {
assertions: [...testCase.assertions, 'expect(...).toBe(...)']
})
}
const handleUpdateAssertion = (caseId: string, index: number, value: string) => {
if (!selectedTest) return
const testCase = selectedTest.testCases.find(c => c.id === caseId)
if (!testCase) return
const newAssertions = [...testCase.assertions]
newAssertions[index] = value
handleUpdateTestCase(caseId, { assertions: newAssertions })
}
const handleDeleteAssertion = (caseId: string, index: number) => {
if (!selectedTest) return
const testCase = selectedTest.testCases.find(c => c.id === caseId)
if (!testCase) return
handleUpdateTestCase(caseId, {
assertions: testCase.assertions.filter((_, i) => i !== index)
})
}
const handleGenerateWithAI = async () => {
const description = prompt('Describe the component/function you want to test:')
if (!description) return
try {
toast.info('Generating test with AI...')
const promptText = `You are a unit test generator. Create tests based on: "${description}"
Return a valid JSON object with a single property "test":
{
"test": {
"id": "unique-id",
"name": "ComponentName/FunctionName Tests",
"description": "Test suite description",
"testType": "component" | "function" | "hook" | "integration",
"targetFile": "/path/to/file.tsx",
"testCases": [
{
"id": "case-id",
"description": "should render correctly",
"assertions": [
"expect(screen.getByText('Hello')).toBeInTheDocument()",
"expect(result).toBe(true)"
],
"setup": "const { getByText } = render(<Component />)",
"teardown": "cleanup()"
}
]
}
}
Create comprehensive test cases with appropriate assertions for React Testing Library or Vitest.`
const response = await window.spark.llm(promptText, 'gpt-4o', true)
const parsed = JSON.parse(response)
onTestsChange([...tests, parsed.test])
setSelectedTestId(parsed.test.id)
toast.success('Test suite generated successfully!')
} catch (error) {
console.error(error)
toast.error('Failed to generate test')
}
}
const getTestTypeColor = (type: string) => {
const colors: Record<string, string> = {
component: 'bg-blue-500',
function: 'bg-green-500',
hook: 'bg-purple-500',
integration: 'bg-orange-500'
}
return colors[type] || 'bg-gray-500'
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-sm">Test Suites</h2>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={handleGenerateWithAI}>
<Sparkle size={14} weight="duotone" />
</Button>
<Button size="sm" onClick={handleAddTest}>
<Plus size={14} />
</Button>
</div>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="p-2 space-y-1">
{tests.map(test => (
<div
key={test.id}
className={`p-3 rounded-md cursor-pointer flex items-start justify-between group ${
selectedTestId === test.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
}`}
onClick={() => setSelectedTestId(test.id)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className={`w-2 h-2 rounded-full ${getTestTypeColor(test.testType)}`} />
<div className="font-medium text-sm truncate">{test.name}</div>
</div>
<div className="text-xs text-muted-foreground truncate">{test.targetFile || 'No file'}</div>
<div className="text-xs text-muted-foreground">{test.testCases.length} cases</div>
</div>
<Button
size="sm"
variant="ghost"
className="opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
handleDeleteTest(test.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
{tests.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No test suites yet. Click + to create one.
</div>
)}
</div>
</ScrollArea>
</div>
<div className="flex-1 p-6">
{selectedTest ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Test Suite Configuration</h2>
<Button variant="outline">
<Flask size={16} className="mr-2" weight="fill" />
Run Tests
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Test Suite Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-name">Test Suite Name</Label>
<Input
id="test-name"
value={selectedTest.name}
onChange={e => handleUpdateTest(selectedTest.id, { name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-description">Description</Label>
<Textarea
id="test-description"
value={selectedTest.description}
onChange={e => handleUpdateTest(selectedTest.id, { description: e.target.value })}
placeholder="What does this test suite cover?"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="test-type">Test Type</Label>
<Select
value={selectedTest.testType}
onValueChange={(value: any) => handleUpdateTest(selectedTest.id, { testType: value })}
>
<SelectTrigger id="test-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component">Component</SelectItem>
<SelectItem value="function">Function</SelectItem>
<SelectItem value="hook">Hook</SelectItem>
<SelectItem value="integration">Integration</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="target-file">Target File</Label>
<Input
id="target-file"
value={selectedTest.targetFile}
onChange={e => handleUpdateTest(selectedTest.id, { targetFile: e.target.value })}
placeholder="/src/components/Button.tsx"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Test Cases</CardTitle>
<CardDescription>Define individual test cases</CardDescription>
</div>
<Button size="sm" onClick={handleAddTestCase}>
<Plus size={14} className="mr-1" />
Add Test Case
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[450px]">
<div className="space-y-4">
{selectedTest.testCases.map((testCase, index) => (
<Card key={testCase.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline">Case {index + 1}</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTestCase(testCase.id)}
>
<Trash size={14} />
</Button>
</div>
<div className="space-y-2">
<Label>Description (it...)</Label>
<Input
value={testCase.description}
onChange={e => handleUpdateTestCase(testCase.id, { description: e.target.value })}
placeholder="should render correctly"
/>
</div>
<div className="space-y-2">
<Label>Setup Code (optional)</Label>
<Textarea
value={testCase.setup || ''}
onChange={e => handleUpdateTestCase(testCase.id, { setup: e.target.value })}
placeholder="const { getByText } = render(<Component />)"
className="font-mono text-xs"
rows={2}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Assertions</Label>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAssertion(testCase.id)}
>
<Plus size={12} />
</Button>
</div>
<div className="space-y-2">
{testCase.assertions.map((assertion, assertionIndex) => (
<div key={assertionIndex} className="flex gap-2">
<Input
value={assertion}
onChange={e => handleUpdateAssertion(testCase.id, assertionIndex, e.target.value)}
placeholder="expect(...).toBe(...)"
className="font-mono text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteAssertion(testCase.id, assertionIndex)}
>
<Trash size={12} />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Teardown Code (optional)</Label>
<Textarea
value={testCase.teardown || ''}
onChange={e => handleUpdateTestCase(testCase.id, { teardown: e.target.value })}
placeholder="cleanup()"
className="font-mono text-xs"
rows={2}
/>
</div>
</CardContent>
</Card>
))}
{selectedTest.testCases.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No test cases yet. Click "Add Test Case" to create one.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Flask size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-lg font-medium mb-2">No test suite selected</p>
<p className="text-sm text-muted-foreground mb-4">
Create or select a test suite to configure
</p>
<Button onClick={handleAddTest}>
<Plus size={16} className="mr-2" />
Create Test Suite
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { PrismaModel, ComponentNode, ThemeConfig } from '@/types/project'
import { PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest } from '@/types/project'
export function generatePrismaSchema(models: PrismaModel[]): string {
let schema = `generator client {\n provider = "prisma-client-js"\n}\n\n`
@@ -241,3 +241,157 @@ Open [http://localhost:3000](http://localhost:3000) with your browser.`
return files
}
export function generatePlaywrightTests(tests: PlaywrightTest[]): string {
if (tests.length === 0) {
return `import { test, expect } from '@playwright/test'
test('example test', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/.*/)
})`
}
let code = `import { test, expect } from '@playwright/test'\n\n`
tests.forEach(testSuite => {
code += `test.describe('${testSuite.name}', () => {\n`
if (testSuite.description) {
code += ` // ${testSuite.description}\n`
}
code += ` test('${testSuite.name}', async ({ page }) => {\n`
testSuite.steps.forEach(step => {
switch (step.action) {
case 'navigate':
code += ` await page.goto('${testSuite.pageUrl}')\n`
break
case 'click':
code += ` await page.click('${step.selector}')\n`
break
case 'fill':
code += ` await page.fill('${step.selector}', '${step.value}')\n`
break
case 'expect':
code += ` await expect(page.locator('${step.selector}')).${step.assertion}\n`
break
case 'wait':
code += ` await page.waitForTimeout(${step.timeout || 1000})\n`
break
case 'select':
code += ` await page.selectOption('${step.selector}', '${step.value}')\n`
break
case 'check':
code += ` await page.check('${step.selector}')\n`
break
case 'uncheck':
code += ` await page.uncheck('${step.selector}')\n`
break
}
})
code += ` })\n`
code += `})\n\n`
})
return code
}
export function generateStorybookStories(stories: StorybookStory[]): Record<string, string> {
const fileMap: Record<string, StorybookStory[]> = {}
stories.forEach(story => {
const key = `${story.category}/${story.componentName}`
if (!fileMap[key]) {
fileMap[key] = []
}
fileMap[key].push(story)
})
const files: Record<string, string> = {}
Object.entries(fileMap).forEach(([path, storyList]) => {
const componentName = storyList[0].componentName
let code = `import type { Meta, StoryObj } from '@storybook/react'\nimport { ${componentName} } from '@/components/${componentName}'\n\n`
code += `const meta: Meta<typeof ${componentName}> = {\n`
code += ` title: '${path}',\n`
code += ` component: ${componentName},\n`
code += ` tags: ['autodocs'],\n`
code += `}\n\n`
code += `export default meta\n`
code += `type Story = StoryObj<typeof ${componentName}>\n\n`
storyList.forEach(story => {
code += `export const ${story.storyName.replace(/\s+/g, '')}: Story = {\n`
if (Object.keys(story.args).length > 0) {
code += ` args: ${JSON.stringify(story.args, null, 4).replace(/"/g, "'")},\n`
}
code += `}\n\n`
})
files[`src/stories/${componentName}.stories.tsx`] = code
})
return files
}
export function generateUnitTests(tests: UnitTest[]): Record<string, string> {
const files: Record<string, string> = {}
tests.forEach(testSuite => {
const fileName = testSuite.targetFile
? testSuite.targetFile.replace(/\.(tsx|ts|jsx|js)$/, '.test.$1')
: `src/__tests__/${testSuite.name.replace(/\s+/g, '')}.test.tsx`
let code = ''
if (testSuite.testType === 'component') {
code += `import { render, screen } from '@testing-library/react'\nimport { describe, it, expect } from 'vitest'\n`
if (testSuite.targetFile) {
const componentName = testSuite.targetFile.split('/').pop()?.replace(/\.(tsx|ts|jsx|js)$/, '')
code += `import { ${componentName} } from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n`
}
} else if (testSuite.testType === 'hook') {
code += `import { renderHook } from '@testing-library/react'\nimport { describe, it, expect } from 'vitest'\n`
if (testSuite.targetFile) {
const hookName = testSuite.targetFile.split('/').pop()?.replace(/\.(tsx|ts|jsx|js)$/, '')
code += `import { ${hookName} } from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n`
}
} else {
code += `import { describe, it, expect } from 'vitest'\n`
if (testSuite.targetFile) {
code += `import * as module from '${testSuite.targetFile.replace('.tsx', '').replace('.ts', '')}'\n\n`
}
}
code += `describe('${testSuite.name}', () => {\n`
if (testSuite.description) {
code += ` // ${testSuite.description}\n\n`
}
testSuite.testCases.forEach(testCase => {
code += ` it('${testCase.description}', () => {\n`
if (testCase.setup) {
code += ` ${testCase.setup}\n\n`
}
testCase.assertions.forEach(assertion => {
code += ` ${assertion}\n`
})
if (testCase.teardown) {
code += `\n ${testCase.teardown}\n`
}
code += ` })\n\n`
})
code += `})\n`
files[fileName] = code
})
return files
}

View File

@@ -64,10 +64,56 @@ export interface ThemeConfig {
borderRadius: number
}
export interface PlaywrightTest {
id: string
name: string
description: string
pageUrl: string
steps: PlaywrightStep[]
}
export interface PlaywrightStep {
id: string
action: 'navigate' | 'click' | 'fill' | 'expect' | 'wait' | 'select' | 'check' | 'uncheck'
selector?: string
value?: string
assertion?: string
timeout?: number
}
export interface StorybookStory {
id: string
componentName: string
storyName: string
args: Record<string, any>
description: string
category: string
}
export interface UnitTest {
id: string
name: string
description: string
testType: 'component' | 'function' | 'hook' | 'integration'
targetFile: string
testCases: TestCase[]
}
export interface TestCase {
id: string
description: string
assertions: string[]
setup?: string
teardown?: string
}
export interface Project {
name: string
files: ProjectFile[]
models: PrismaModel[]
components: ComponentNode[]
theme: ThemeConfig
playwrightTests?: PlaywrightTest[]
storybookStories?: StorybookStory[]
unitTests?: UnitTest[]
}