import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { skillMarketApi } from '../api/skillMarket' import { useSettingsStore } from '../stores/settingsStore' import { useSkillMarketStore } from '../stores/skillMarketStore' import { useSkillStore } from '../stores/skillStore' import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket' import { SkillCenter } from './SkillCenter' vi.mock('../api/skillMarket', () => ({ skillMarketApi: { list: vi.fn(), detail: vi.fn(), install: vi.fn(), }, })) vi.mock('../components/skills/SkillList', () => ({ SkillList: () =>
Installed skills
, })) vi.mock('../components/markdown/MarkdownRenderer', () => ({ MarkdownRenderer: ({ content }: { content: string }) => (
{content}
), })) vi.mock('../components/chat/CodeViewer', () => ({ CodeViewer: ({ code }: { code: string }) =>
{code}
, })) const mockedSkillMarketApi = vi.mocked(skillMarketApi) function makeItem(overrides: Partial = {}): SkillMarketItem { return { source: 'clawhub', sourceMode: 'primary', slug: 'ppt-generator', displayName: 'PPT Generator', summary: 'Create presentation decks from structured prompts.', owner: 'OpenClaw', canonicalUrl: 'https://clawhub.ai/skills/ppt-generator', upstreamUrl: 'https://github.com/example/ppt-generator', license: 'MIT', version: '1.0.0', downloads: 42000, stars: 128, trustState: 'clean', installed: false, ...overrides, } } function makeDetail(overrides: Partial = {}): SkillMarketDetail { return { ...makeItem(), files: [{ path: 'SKILL.md', size: 512 }], filePreviews: [ { path: 'SKILL.md', content: '# PPT Generator', language: 'markdown', size: 512, }, ], entryPreview: '# PPT Generator', riskLabels: [], installEligibility: { status: 'installable' }, ...overrides, } } describe('SkillCenter', () => { beforeEach(() => { useSettingsStore.setState({ locale: 'en' }) useSkillMarketStore.setState({ items: [], nextCursor: null, selectedDetail: null, source: 'auto', resolvedSource: null, sourceStatus: null, statusMessage: null, sort: 'downloads', query: '', isLoading: false, isLoadingMore: false, isDetailLoading: false, isInstalling: false, error: null, }) useSkillStore.setState({ skills: [], selectedSkill: null, selectedSkillReturnTab: 'skills', isLoading: false, isDetailLoading: false, error: null, fetchSkills: vi.fn(), fetchSkillDetail: vi.fn(), clearSelection: vi.fn(() => useSkillStore.setState({ selectedSkill: null })), }) mockedSkillMarketApi.list.mockResolvedValue({ items: [makeItem()], nextCursor: null, source: 'clawhub', sourceStatus: 'ok', }) mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail() }) mockedSkillMarketApi.install.mockResolvedValue({ installed: true, skillName: 'ppt-generator', targetPath: '/Users/nanmi/.claude/skills/ppt-generator', }) }) afterEach(() => { cleanup() vi.clearAllMocks() useSettingsStore.setState({ locale: 'en' }) useSkillMarketStore.setState({ items: [], nextCursor: null, selectedDetail: null, source: 'auto', resolvedSource: null, sourceStatus: null, statusMessage: null, sort: 'downloads', query: '', isLoading: false, isLoadingMore: false, isDetailLoading: false, isInstalling: false, error: null, }) useSkillStore.setState({ skills: [], selectedSkill: null, selectedSkillReturnTab: 'skills', isLoading: false, isDetailLoading: false, error: null, }) }) it('loads marketplace cards and opens a detail confirmation panel', async () => { const { container } = render() expect(screen.getByRole('tab', { name: 'Marketplace' })).toHaveAttribute('aria-selected', 'true') expect(await screen.findByRole('button', { name: 'PPT Generator' })).toBeInTheDocument() expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({ source: 'auto', sort: 'downloads', q: undefined, limit: 100, }) fireEvent.click(screen.getByRole('button', { name: 'PPT Generator' })) await waitFor(() => { expect(mockedSkillMarketApi.detail).toHaveBeenCalledWith('clawhub', 'ppt-generator') }) const detail = await screen.findByText('# PPT Generator') expect(detail).toBeInTheDocument() const dialog = screen.getByRole('dialog', { name: 'PPT Generator' }) const detailLayer = screen.getByTestId('skill-market-detail-layer') expect(container.contains(dialog)).toBe(false) expect(document.body.contains(dialog)).toBe(true) expect(detailLayer).toHaveClass('fixed', 'inset-0', 'z-50') expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled() expect(screen.getByText('Open upstream')).toHaveAttribute('href', 'https://github.com/example/ppt-generator') expect(screen.getByText('File preview')).toBeInTheDocument() expect(screen.getByText('SKILL.md')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Install' })) expect(mockedSkillMarketApi.install).not.toHaveBeenCalled() expect(screen.getByRole('dialog', { name: 'Confirm skill install' })).toBeInTheDocument() expect(screen.getByText('~/.claude/skills/ppt-generator')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Install skill' })) await waitFor(() => { expect(mockedSkillMarketApi.install).toHaveBeenCalledWith('clawhub', 'ppt-generator', '1.0.0') }) }) it('renders marketplace cards with trust, preview, install, API key, and popularity signals', async () => { mockedSkillMarketApi.list.mockResolvedValue({ items: [ makeItem({ slug: 'skill-vetter', displayName: 'Skill Vetter', summary: 'Security-first skill vetting.', downloads: 260960, trustState: 'clean', installed: false, }), makeItem({ slug: 'weather', displayName: 'Weather', summary: 'Fetch current weather through a command-line workflow.', downloads: 18420, trustState: 'clean', installed: true, requiresApiKey: false, }), makeItem({ slug: 'crm-sync', displayName: 'CRM Sync', summary: 'Connects to an external CRM API.', downloads: 7420, trustState: 'warning', requiresApiKey: true, }), ], nextCursor: null, source: 'clawhub', sourceStatus: 'ok', }) render() const market = screen.getByTestId('skill-marketplace-tab') const vetterCard = await within(market).findByRole('button', { name: 'Skill Vetter' }) const weatherCard = within(market).getByRole('button', { name: 'Weather' }) const crmCard = within(market).getByRole('button', { name: 'CRM Sync' }) expect(vetterCard).toBeInTheDocument() expect(within(market).getAllByText('Clean').length).toBeGreaterThan(0) expect(within(market).getAllByText('Preview').length).toBeGreaterThan(0) expect(within(weatherCard).getByText('Installed')).toBeInTheDocument() expect(within(crmCard).getByText('API key')).toBeInTheDocument() expect(within(vetterCard).getByText(/260\.9K|260,960/)).toBeInTheDocument() }) it('uses marketplace-shaped loading skeletons while the catalog loads', () => { mockedSkillMarketApi.list.mockReturnValue(new Promise(() => {})) act(() => { useSkillMarketStore.setState({ isLoading: true, items: [], }) }) render() expect(screen.getByTestId('skill-marketplace-loading')).toBeInTheDocument() expect(screen.getAllByTestId('skill-card-skeleton')).toHaveLength(8) }) it('places install decision, blocking reason, and file preview in the drawer decision area', async () => { mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail({ installEligibility: { status: 'blocked', reason: 'A full package safety scan is required before install.' }, trustSummary: 'Scanner found no known malicious content, but the package scan is incomplete.', filePreviews: [{ path: 'SKILL.md', content: '# Skill Vetter\n\nSecurity-first skill vetting.', language: 'markdown', size: 512, }], }), }) render() fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' })) const dialog = await screen.findByRole('dialog', { name: 'PPT Generator' }) expect(within(dialog).getByRole('button', { name: 'Blocked' })).toBeDisabled() expect(within(dialog).getByText(/full package safety scan/i)).toBeInTheDocument() expect(within(dialog).getByText('File preview')).toBeInTheDocument() expect(within(dialog).getByText('SKILL.md')).toBeInTheDocument() }) it('renders raw Markdown and Python previews for an uninstalled marketplace skill', async () => { mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail({ files: [ { path: 'SKILL.md', size: 256, contentType: 'text/markdown' }, { path: 'scripts/audit.py', size: 42, contentType: 'text/x-python' }, ], filePreviews: [ { path: 'SKILL.md', content: '# Audit Skill\n\nUse this before install.', language: 'markdown', size: 256, }, { path: 'scripts/audit.py', content: 'print("audit")\n', language: 'python', size: 42, }, ], entryPreview: '# Audit Skill', riskLabels: ['scripts', 'executables'], }), }) render() fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' })) expect(await screen.findByText('File preview')).toBeInTheDocument() expect(screen.getByText('scripts/audit.py')).toBeInTheDocument() expect(screen.getByText('python')).toBeInTheDocument() expect(screen.getByText(/print\("audit"\)/)).toBeInTheDocument() expect(screen.getByText('Scripts')).toBeInTheDocument() expect(screen.getByText('Executables')).toBeInTheDocument() }) it('explains when marketplace file preview is unavailable', async () => { mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail({ source: 'skillhub', sourceMode: 'fallback', filePreviews: [], entryPreview: undefined, previewUnavailableReason: 'SkillHub does not expose a safe raw file preview endpoint yet.', }), }) render() fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' })) expect(await screen.findByText('Preview unavailable')).toBeInTheDocument() expect(screen.getByText('SkillHub does not expose a safe raw file preview endpoint yet.')).toBeInTheDocument() }) it('shows marketplace source fallback status without losing the selected source control', async () => { mockedSkillMarketApi.list.mockResolvedValue({ items: [makeItem({ source: 'skillhub', sourceMode: 'fallback' })], nextCursor: null, source: 'skillhub', sourceStatus: 'fallback', message: 'ClawHub unavailable, using SkillHub.', }) render() expect(await screen.findByText('ClawHub unavailable, using SkillHub.')).toBeInTheDocument() expect(screen.getByText('Fallback active')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Auto' })).toHaveAttribute('aria-pressed', 'true') }) it('replaces raw network failures with a user-facing marketplace error', async () => { mockedSkillMarketApi.list.mockRejectedValue(new Error('Failed to fetch')) render() expect(await screen.findByText('Marketplace unavailable')).toBeInTheDocument() expect(screen.getByText(/could not be reached/)).toBeInTheDocument() expect(screen.queryByText('Failed to fetch')).not.toBeInTheDocument() }) it('opens the installed skill detail from an installed marketplace item', async () => { const fetchSkillDetail = vi.fn() useSkillStore.setState({ skills: [ { name: 'ppt-generator', displayName: 'PPT Generator', description: 'Installed local skill', source: 'user', userInvocable: true, contentLength: 80, hasDirectory: true, }, ], fetchSkillDetail, }) mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail({ installed: true, installEligibility: { status: 'installed', installedSkillName: 'ppt-generator' }, }), }) render() fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' })) const viewInstalledButton = await screen.findByRole('button', { name: 'View installed' }) expect(viewInstalledButton).toHaveClass('bg-[var(--color-success-container)]') fireEvent.click(viewInstalledButton) expect(screen.getByRole('tab', { name: 'Mine' })).toHaveAttribute('aria-selected', 'true') expect(fetchSkillDetail).toHaveBeenCalledWith('user', 'ppt-generator', undefined, 'skills') }) it('submits market searches without auto-searching every keystroke', async () => { render() await screen.findByRole('button', { name: 'PPT Generator' }) mockedSkillMarketApi.list.mockClear() fireEvent.change(screen.getByLabelText('Search'), { target: { value: ' ppt ' }, }) expect(mockedSkillMarketApi.list).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Run search' })) await waitFor(() => { expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({ source: 'auto', sort: 'downloads', q: 'ppt', limit: 100, }) }) }) it('marks blocked marketplace details as not installable', async () => { mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail({ trustState: 'blocked', riskLabels: ['scripts'], installEligibility: { status: 'blocked', reason: 'Risky script detected.' }, }), }) render() fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' })) const detailPanel = await screen.findByText('Risk signals') expect(detailPanel).toBeInTheDocument() expect(screen.getByText('Scripts')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Blocked' })).toBeDisabled() }) it('switches to the installed skills tab', async () => { render() await screen.findByRole('button', { name: 'PPT Generator' }) fireEvent.click(screen.getByRole('tab', { name: 'Mine' })) expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument() expect(within(screen.getByTestId('skill-mine-tab')).getByTestId('installed-skill-list')).toBeInTheDocument() }) it('opens the mine tab when an installed skill detail is already selected', async () => { useSkillStore.setState({ selectedSkill: { meta: { name: 'local-ppt', displayName: 'Local PPT', description: 'Installed local skill', source: 'user', userInvocable: true, contentLength: 80, hasDirectory: true, }, tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }], files: [ { path: 'SKILL.md', content: '# Local PPT', body: '# Local PPT', language: 'markdown', isEntry: true, }, ], skillRoot: '/tmp/local-ppt', }, }) render() expect(await screen.findByText('Local PPT')).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Mine' })).toHaveAttribute('aria-selected', 'true') expect(mockedSkillMarketApi.list).not.toHaveBeenCalled() }) })