mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-25 22:25:01 +00:00
Generated by Spark: Designers for Playwright, Storybook and unit testing.
This commit is contained in:
335
src/components/PlaywrightDesigner.tsx
Normal file
335
src/components/PlaywrightDesigner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
324
src/components/StorybookDesigner.tsx
Normal file
324
src/components/StorybookDesigner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
396
src/components/UnitTestDesigner.tsx
Normal file
396
src/components/UnitTestDesigner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user