Skip to content

Commit 85ea5e6

Browse files
authored
Merge pull request #33 from spivx/skills-e2e
Skills e2e
2 parents 2ab580a + d02f90a commit 85ea5e6

5 files changed

Lines changed: 190 additions & 7 deletions

File tree

app/skills/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default function SkillsPage() {
3535
const skills = getAllSkills();
3636

3737
return (
38-
<div className="relative min-h-screen bg-background text-foreground">
38+
<div className="relative min-h-screen bg-background text-foreground" data-testid="skills-page">
3939
<script
4040
type="application/ld+json"
4141
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
@@ -62,7 +62,7 @@ export default function SkillsPage() {
6262
</div>
6363
</header>
6464

65-
<main className="container mx-auto max-w-7xl px-4 py-8 lg:px-8 flex-1">
65+
<main className="container mx-auto max-w-7xl px-4 py-8 lg:px-8 flex-1" data-testid="skills-main-content">
6666
<div className="mb-8 space-y-4">
6767
<div className="flex flex-col gap-2">
6868
<span className="text-sm font-semibold uppercase tracking-wider text-primary">Beta</span>

components/skill-builder-form.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,10 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
141141
className="pl-9"
142142
value={searchQuery}
143143
onChange={(e) => setSearchQuery(e.target.value)}
144+
data-testid="skill-search-input"
144145
/>
145146
</div>
146-
<Button onClick={handleCreateNew} variant="secondary">
147+
<Button onClick={handleCreateNew} variant="secondary" data-testid="create-custom-skill-button">
147148
Create Custom
148149
</Button>
149150
</div>
@@ -159,6 +160,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
159160
selectedSkillId === skill.id && "border-primary bg-primary/5 ring-1 ring-primary"
160161
)}
161162
onClick={() => loadTemplate(skill)}
163+
data-testid={`skill-card-${skill.id}`}
162164
>
163165
<div className="flex items-start justify-between gap-2">
164166
<div className="flex items-center gap-2">
@@ -198,6 +200,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
198200
exit={{ x: "100%" }}
199201
transition={{ type: "spring", damping: 20, stiffness: 300 }}
200202
className="fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-2xl flex-col border-l bg-background shadow-xl sm:max-w-[800px]"
203+
data-testid="skill-drawer"
201204
>
202205
{/* Drawer Header */}
203206
<div className="flex items-center justify-between border-b px-6 py-4">
@@ -209,7 +212,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
209212
Customize metadata and content before downloading.
210213
</p>
211214
</div>
212-
<Button variant="ghost" size="icon" onClick={handleCloseDrawer}>
215+
<Button variant="ghost" size="icon" onClick={handleCloseDrawer} data-testid="close-drawer-button">
213216
<X className="h-5 w-5" />
214217
</Button>
215218
</div>
@@ -225,6 +228,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
225228
? "bg-background shadow text-foreground"
226229
: "text-muted-foreground hover:text-foreground"
227230
)}
231+
data-testid="editor-tab-button"
228232
>
229233
<Settings2 className="h-4 w-4" />
230234
Editor
@@ -237,13 +241,14 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
237241
? "bg-background shadow text-foreground"
238242
: "text-muted-foreground hover:text-foreground"
239243
)}
244+
data-testid="preview-tab-button"
240245
>
241246
<Eye className="h-4 w-4" />
242247
Preview
243248
</button>
244249
</div>
245250

246-
<Button onClick={handleDownload} size="sm">
251+
<Button onClick={handleDownload} size="sm" data-testid="download-skill-button">
247252
<Download className="mr-2 h-4 w-4" />
248253
Download
249254
</Button>
@@ -299,11 +304,12 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
299304
className="min-h-[400px] font-mono text-sm leading-relaxed"
300305
value={data.content}
301306
onChange={(e) => handleChange("content", e.target.value)}
307+
data-testid="skill-content-input"
302308
/>
303309
</div>
304310
</div>
305311
) : (
306-
<div className="h-full rounded-lg border bg-muted/30 p-6 font-mono text-sm whitespace-pre-wrap animate-in fade-in slide-in-from-bottom-2 duration-300">
312+
<div className="h-full rounded-lg border bg-muted/30 p-6 font-mono text-sm whitespace-pre-wrap animate-in fade-in slide-in-from-bottom-2 duration-300" data-testid="skill-preview-content">
307313
{preview}
308314
</div>
309315
)}

lib/__tests__/skills.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import fs from 'fs'
3+
import path from 'path'
4+
import { getAllSkills } from '../skills'
5+
6+
vi.mock('fs')
7+
8+
describe('getAllSkills', () => {
9+
const mockSkillsDir = path.join(process.cwd(), 'skills')
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks()
13+
})
14+
15+
it('returns an empty array if the skills directory does not exist', () => {
16+
vi.mocked(fs.existsSync).mockReturnValue(false)
17+
18+
const skills = getAllSkills()
19+
20+
expect(skills).toEqual([])
21+
expect(fs.existsSync).toHaveBeenCalledWith(mockSkillsDir)
22+
})
23+
24+
it('returns parsed skills from markdown files', () => {
25+
const mockFileNames = ['skill-b.md', 'skill-a.md', 'not-a-skill.txt']
26+
vi.mocked(fs.existsSync).mockReturnValue(true)
27+
vi.mocked(fs.readdirSync).mockReturnValue(mockFileNames as any)
28+
29+
const fileAContent = `---
30+
name: Skill A
31+
description: Description A
32+
dependencies: dep-a
33+
---
34+
Content A`
35+
const fileBContent = `---
36+
name: Skill B
37+
description: Description B
38+
---
39+
Content B`
40+
41+
vi.mocked(fs.readFileSync).mockImplementation((path: any) => {
42+
if (path.toString().endsWith('skill-a.md')) return fileAContent
43+
if (path.toString().endsWith('skill-b.md')) return fileBContent
44+
return ''
45+
})
46+
47+
const skills = getAllSkills()
48+
49+
expect(skills).toHaveLength(2)
50+
51+
// Should be sorted by name
52+
expect(skills[0]).toEqual({
53+
id: 'skill-a',
54+
name: 'Skill A',
55+
description: 'Description A',
56+
dependencies: 'dep-a',
57+
content: 'Content A'
58+
})
59+
expect(skills[1]).toEqual({
60+
id: 'skill-b',
61+
name: 'Skill B',
62+
description: 'Description B',
63+
dependencies: '',
64+
content: 'Content B'
65+
})
66+
})
67+
68+
it('uses id as name if name is missing in frontmatter', () => {
69+
vi.mocked(fs.existsSync).mockReturnValue(true)
70+
vi.mocked(fs.readdirSync).mockReturnValue(['test-skill.md'] as any)
71+
vi.mocked(fs.readFileSync).mockReturnValue('---\ndescription: desc\n---\ncontent')
72+
73+
const skills = getAllSkills()
74+
75+
expect(skills[0].name).toBe('test-skill')
76+
expect(skills[0].id).toBe('test-skill')
77+
})
78+
79+
it('sorts skills alphabetically by name', () => {
80+
vi.mocked(fs.existsSync).mockReturnValue(true)
81+
vi.mocked(fs.readdirSync).mockReturnValue(['z.md', 'a.md'] as any)
82+
83+
vi.mocked(fs.readFileSync).mockImplementation((path: any) => {
84+
if (path.toString().endsWith('z.md')) return '---\nname: Zebra\n---\ncontent'
85+
if (path.toString().endsWith('a.md')) return '---\nname: Apple\n---\ncontent'
86+
return ''
87+
})
88+
89+
const skills = getAllSkills()
90+
91+
expect(skills[0].name).toBe('Apple')
92+
expect(skills[1].name).toBe('Zebra')
93+
})
94+
})

lib/site-metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const DEFAULT_SITE_URL = "https://devcontext.xyz";
1+
const DEFAULT_SITE_URL = "https://www.devcontext.xyz";
22

33
const normalizeSiteUrl = (input: string): string => {
44
const trimmed = input.trim();

playwright/tests/skills.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('Skills Marketplace', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/skills')
6+
})
7+
8+
test('should load the skills marketplace page', async ({ page }) => {
9+
await expect(page.getByTestId('skills-page')).toBeVisible()
10+
await expect(page.getByRole('heading', { name: 'Skills Marketplace' })).toBeVisible()
11+
await expect(page.getByTestId('skill-search-input')).toBeVisible()
12+
})
13+
14+
test('should filter skills by search query', async ({ page }) => {
15+
await page.getByTestId('skill-search-input').fill('react')
16+
17+
const cards = page.locator('[data-testid^="skill-card-"]')
18+
const count = await cards.count()
19+
20+
if (count > 0) {
21+
for (let i = 0; i < count; i++) {
22+
const text = await cards.nth(i).textContent()
23+
expect(text?.toLowerCase()).toContain('react')
24+
}
25+
}
26+
})
27+
28+
test('should open skill drawer when clicking a skill card', async ({ page }) => {
29+
const firstCard = page.locator('[data-testid^="skill-card-"]').first()
30+
await expect(firstCard).toBeVisible()
31+
32+
const skillName = await firstCard.locator('h3').textContent()
33+
await firstCard.click()
34+
35+
await expect(page.getByTestId('skill-drawer')).toBeVisible()
36+
await expect(page.getByRole('heading', { name: 'Edit Skill' })).toBeVisible()
37+
38+
await expect(page.locator('#name')).toHaveValue(skillName || '')
39+
})
40+
41+
test('should switch between editor and preview tabs', async ({ page }) => {
42+
await page.getByTestId('create-custom-skill-button').click()
43+
await expect(page.getByTestId('skill-drawer')).toBeVisible()
44+
45+
// Default should be editor
46+
await expect(page.getByTestId('skill-content-input')).toBeVisible()
47+
48+
// Switch to preview
49+
await page.getByTestId('preview-tab-button').click()
50+
await expect(page.getByTestId('skill-preview-content')).toBeVisible()
51+
await expect(page.getByTestId('skill-content-input')).not.toBeVisible()
52+
53+
// Switch back to editor
54+
await page.getByTestId('editor-tab-button').click()
55+
await expect(page.getByTestId('skill-content-input')).toBeVisible()
56+
await expect(page.getByTestId('skill-preview-content')).not.toBeVisible()
57+
})
58+
59+
test('should be able to close the drawer', async ({ page }) => {
60+
await page.getByTestId('create-custom-skill-button').click()
61+
await expect(page.getByTestId('skill-drawer')).toBeVisible()
62+
63+
await page.getByTestId('close-drawer-button').click()
64+
await expect(page.getByTestId('skill-drawer')).not.toBeVisible()
65+
})
66+
67+
test('should allow creating a custom skill', async ({ page }) => {
68+
await page.getByTestId('create-custom-skill-button').click()
69+
await expect(page.getByTestId('skill-drawer')).toBeVisible()
70+
await expect(page.getByRole('heading', { name: 'Create New Skill' })).toBeVisible()
71+
72+
await page.locator('#name').fill('New Test Skill')
73+
await page.locator('#description').fill('A description for the test skill')
74+
await page.getByTestId('skill-content-input').fill('## Test Content')
75+
76+
// Switch to preview to verify
77+
await page.getByTestId('preview-tab-button').click()
78+
const previewContent = await page.getByTestId('skill-preview-content').textContent()
79+
expect(previewContent).toContain('New Test Skill')
80+
expect(previewContent).toContain('A description for the test skill')
81+
expect(previewContent).toContain('## Test Content')
82+
})
83+
})

0 commit comments

Comments
 (0)