mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
472 lines
16 KiB
TypeScript
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()
|
|
})
|
|
})
|