Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/skills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function SkillsPage() {
const skills = getAllSkills();

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

<main className="container mx-auto max-w-7xl px-4 py-8 lg:px-8 flex-1">
<main className="container mx-auto max-w-7xl px-4 py-8 lg:px-8 flex-1" data-testid="skills-main-content">
<div className="mb-8 space-y-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold uppercase tracking-wider text-primary">Beta</span>
Expand Down
14 changes: 10 additions & 4 deletions components/skill-builder-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="skill-search-input"
/>
</div>
<Button onClick={handleCreateNew} variant="secondary">
<Button onClick={handleCreateNew} variant="secondary" data-testid="create-custom-skill-button">
Create Custom
</Button>
</div>
Expand All @@ -159,6 +160,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
selectedSkillId === skill.id && "border-primary bg-primary/5 ring-1 ring-primary"
)}
onClick={() => loadTemplate(skill)}
data-testid={`skill-card-${skill.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -198,6 +200,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
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]"
data-testid="skill-drawer"
>
{/* Drawer Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
Expand All @@ -209,7 +212,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
Customize metadata and content before downloading.
</p>
</div>
<Button variant="ghost" size="icon" onClick={handleCloseDrawer}>
<Button variant="ghost" size="icon" onClick={handleCloseDrawer} data-testid="close-drawer-button">
<X className="h-5 w-5" />
</Button>
</div>
Expand All @@ -225,6 +228,7 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
? "bg-background shadow text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="editor-tab-button"
>
<Settings2 className="h-4 w-4" />
Editor
Expand All @@ -237,13 +241,14 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
? "bg-background shadow text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="preview-tab-button"
>
<Eye className="h-4 w-4" />
Preview
</button>
</div>

<Button onClick={handleDownload} size="sm">
<Button onClick={handleDownload} size="sm" data-testid="download-skill-button">
<Download className="mr-2 h-4 w-4" />
Download
</Button>
Expand Down Expand Up @@ -299,11 +304,12 @@ export function SkillBuilderForm({ initialSkills }: SkillBuilderFormProps) {
className="min-h-[400px] font-mono text-sm leading-relaxed"
value={data.content}
onChange={(e) => handleChange("content", e.target.value)}
data-testid="skill-content-input"
/>
</div>
</div>
) : (
<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">
<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">
{preview}
</div>
)}
Expand Down
94 changes: 94 additions & 0 deletions lib/__tests__/skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import fs from 'fs'
import path from 'path'
import { getAllSkills } from '../skills'

vi.mock('fs')

describe('getAllSkills', () => {
const mockSkillsDir = path.join(process.cwd(), 'skills')

beforeEach(() => {
vi.clearAllMocks()
})

it('returns an empty array if the skills directory does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false)

const skills = getAllSkills()

expect(skills).toEqual([])
expect(fs.existsSync).toHaveBeenCalledWith(mockSkillsDir)
})

it('returns parsed skills from markdown files', () => {
const mockFileNames = ['skill-b.md', 'skill-a.md', 'not-a-skill.txt']
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readdirSync).mockReturnValue(mockFileNames as any)

const fileAContent = `---
name: Skill A
description: Description A
dependencies: dep-a
---
Content A`
const fileBContent = `---
name: Skill B
description: Description B
---
Content B`

vi.mocked(fs.readFileSync).mockImplementation((path: any) => {
if (path.toString().endsWith('skill-a.md')) return fileAContent
if (path.toString().endsWith('skill-b.md')) return fileBContent
return ''
})

const skills = getAllSkills()

expect(skills).toHaveLength(2)

// Should be sorted by name
expect(skills[0]).toEqual({
id: 'skill-a',
name: 'Skill A',
description: 'Description A',
dependencies: 'dep-a',
content: 'Content A'
})
expect(skills[1]).toEqual({
id: 'skill-b',
name: 'Skill B',
description: 'Description B',
dependencies: '',
content: 'Content B'
})
})

it('uses id as name if name is missing in frontmatter', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readdirSync).mockReturnValue(['test-skill.md'] as any)
vi.mocked(fs.readFileSync).mockReturnValue('---\ndescription: desc\n---\ncontent')

const skills = getAllSkills()

expect(skills[0].name).toBe('test-skill')
expect(skills[0].id).toBe('test-skill')
})

it('sorts skills alphabetically by name', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readdirSync).mockReturnValue(['z.md', 'a.md'] as any)

vi.mocked(fs.readFileSync).mockImplementation((path: any) => {
if (path.toString().endsWith('z.md')) return '---\nname: Zebra\n---\ncontent'
if (path.toString().endsWith('a.md')) return '---\nname: Apple\n---\ncontent'
return ''
})

const skills = getAllSkills()

expect(skills[0].name).toBe('Apple')
expect(skills[1].name).toBe('Zebra')
})
})
2 changes: 1 addition & 1 deletion lib/site-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const DEFAULT_SITE_URL = "https://devcontext.xyz";
const DEFAULT_SITE_URL = "https://www.devcontext.xyz";

const normalizeSiteUrl = (input: string): string => {
const trimmed = input.trim();
Expand Down
83 changes: 83 additions & 0 deletions playwright/tests/skills.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test'

test.describe('Skills Marketplace', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/skills')
})

test('should load the skills marketplace page', async ({ page }) => {
await expect(page.getByTestId('skills-page')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Skills Marketplace' })).toBeVisible()
await expect(page.getByTestId('skill-search-input')).toBeVisible()
})

test('should filter skills by search query', async ({ page }) => {
await page.getByTestId('skill-search-input').fill('react')

const cards = page.locator('[data-testid^="skill-card-"]')
const count = await cards.count()

if (count > 0) {
for (let i = 0; i < count; i++) {
const text = await cards.nth(i).textContent()
expect(text?.toLowerCase()).toContain('react')
}
}
})

test('should open skill drawer when clicking a skill card', async ({ page }) => {
const firstCard = page.locator('[data-testid^="skill-card-"]').first()
await expect(firstCard).toBeVisible()

const skillName = await firstCard.locator('h3').textContent()
await firstCard.click()

await expect(page.getByTestId('skill-drawer')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Edit Skill' })).toBeVisible()

await expect(page.locator('#name')).toHaveValue(skillName || '')
})

test('should switch between editor and preview tabs', async ({ page }) => {
await page.getByTestId('create-custom-skill-button').click()
await expect(page.getByTestId('skill-drawer')).toBeVisible()

// Default should be editor
await expect(page.getByTestId('skill-content-input')).toBeVisible()

// Switch to preview
await page.getByTestId('preview-tab-button').click()
await expect(page.getByTestId('skill-preview-content')).toBeVisible()
await expect(page.getByTestId('skill-content-input')).not.toBeVisible()

// Switch back to editor
await page.getByTestId('editor-tab-button').click()
await expect(page.getByTestId('skill-content-input')).toBeVisible()
await expect(page.getByTestId('skill-preview-content')).not.toBeVisible()
})

test('should be able to close the drawer', async ({ page }) => {
await page.getByTestId('create-custom-skill-button').click()
await expect(page.getByTestId('skill-drawer')).toBeVisible()

await page.getByTestId('close-drawer-button').click()
await expect(page.getByTestId('skill-drawer')).not.toBeVisible()
})

test('should allow creating a custom skill', async ({ page }) => {
await page.getByTestId('create-custom-skill-button').click()
await expect(page.getByTestId('skill-drawer')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Create New Skill' })).toBeVisible()

await page.locator('#name').fill('New Test Skill')
await page.locator('#description').fill('A description for the test skill')
await page.getByTestId('skill-content-input').fill('## Test Content')

// Switch to preview to verify
await page.getByTestId('preview-tab-button').click()
const previewContent = await page.getByTestId('skill-preview-content').textContent()
expect(previewContent).toContain('New Test Skill')
expect(previewContent).toContain('A description for the test skill')
expect(previewContent).toContain('## Test Content')
})
})