cc-haha/desktop/src/pages/SkillCenter.test.tsx
2026-07-05 00:23:53 +08:00

472 lines
16 KiB
TypeScript

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: () => <div data-testid="installed-skill-list">Installed skills</div>,
}))
vi.mock('../components/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <div data-testid="code-viewer">{code}</div>,
}))
const mockedSkillMarketApi = vi.mocked(skillMarketApi)
function makeItem(overrides: Partial<SkillMarketItem> = {}): 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> = {}): 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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
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(<SkillCenter />)
expect(await screen.findByText('Local PPT')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Mine' })).toHaveAttribute('aria-selected', 'true')
expect(mockedSkillMarketApi.list).not.toHaveBeenCalled()
})
})