import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen' import type { SkillReferenceResponse, SkillResponse, SkillTagResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import SkillsPage from '../page' type SkillsInfiniteOptions = { getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined initialPageParam: number input: (pageParam: unknown) => { query: Record } } const mocks = vi.hoisted(() => ({ createSkillMutationFn: vi.fn(), deleteSkillMutationFn: vi.fn(), downloadBlob: vi.fn(), duplicateSkillMutationFn: vi.fn(), exportSkillArchiveBlob: vi.fn(), importSkillMutationFn: vi.fn(), push: vi.fn(), genericTags: [] as Tag[], genericTagsQueryOptions: vi.fn((_options: unknown) => ({})), queryState: { keyword: '', tag: [] as string[], }, skills: [] as SkillResponse[], skillPages: [] as SkillResponse[][], skillsKey: vi.fn((_options: unknown): unknown[] => ['skills']), skillsQueryOptions: vi.fn((_options: SkillsInfiniteOptions) => ({})), skillReferences: [] as SkillReferenceResponse[], skillReferencesQueryOptions: vi.fn((_options: unknown) => ({})), tags: [] as SkillTagResponse[], tagsKey: vi.fn((_options: unknown): unknown[] => ['skill-tags']), tagsQueryOptions: vi.fn((_options: unknown) => ({})), })) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: vi.fn(), success: vi.fn(), }, })) vi.mock('ahooks', () => ({ useDebounce: (value: unknown) => value, })) vi.mock('nuqs', async () => { const React = await import('react') const listeners = new Map<'keyword' | 'tag', Set<() => void>>() const createParser = () => ({ withDefault: () => ({ withOptions: () => ({}), }), }) return { debounce: () => undefined, parseAsArrayOf: () => ({ withDefault: () => ({}), }), parseAsString: createParser(), useQueryState: (name: 'keyword' | 'tag') => { const [value, setValue] = React.useState(mocks.queryState[name]) React.useEffect(() => { const nameListeners = listeners.get(name) ?? new Set<() => void>() listeners.set(name, nameListeners) const listener = () => setValue(mocks.queryState[name]) nameListeners.add(listener) return () => { nameListeners.delete(listener) } }, [name]) const setQueryValue = (nextValue: string | string[]) => { mocks.queryState[name] = nextValue as never setValue(nextValue as never) listeners.get(name)?.forEach((listener) => listener()) return Promise.resolve(new URLSearchParams()) } return [value, setQueryValue] as const }, } }) vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: () => '2 hours ago', }), })) vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: () => '2026-07-22 10:00', }), })) vi.mock('@/app/components/base/app-icon', () => ({ default: ({ icon }: { icon?: string }) => {icon}, })) vi.mock('@/features/tag-management/components/skill-card-tags', () => ({ SkillCardTags: ({ tags }: { tags: string[] }) => ( ), })) vi.mock('../skill-list-tag-management-modal', () => ({ SkillListTagManagementModal: () => null, })) vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: { children: ReactNode; href: string }) => ( {children} ), })) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mocks.push, }), })) vi.mock('@/utils/download', () => ({ downloadBlob: mocks.downloadBlob, })) vi.mock('../client', () => ({ fetchSkillArchiveBlob: mocks.exportSkillArchiveBlob, uploadSkillFile: vi.fn(), })) vi.mock('@/service/console', () => ({ consoleQuery: { tags: { get: { queryOptions: mocks.genericTagsQueryOptions, }, }, workspaces: { current: { skills: { get: { key: mocks.skillsKey, infiniteOptions: mocks.skillsQueryOptions, }, post: { mutationOptions: () => ({ mutationFn: mocks.createSkillMutationFn }), }, import: { post: { mutationOptions: () => ({ mutationFn: mocks.importSkillMutationFn }), }, }, tags: { get: { key: mocks.tagsKey, queryOptions: mocks.tagsQueryOptions, }, }, bySkillId: { delete: { mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }), }, references: { get: { queryOptions: mocks.skillReferencesQueryOptions, }, }, duplicate: { post: { mutationOptions: () => ({ mutationFn: mocks.duplicateSkillMutationFn }), }, }, }, }, }, }, }, })) vi.mock('../permissions', () => ({ useSkillPermissions: () => ({ canDelete: true, canEdit: true, canPublish: true }), })) function createSkill(overrides: Partial = {}): SkillResponse { return { id: 'skill-1', name: 'refund-approval', display_name: 'Refund approval', icon: '💳', description: 'Handle refund requests.', tags: ['support'], visibility: 'workspace', latest_published_version_id: 'version-1', latest_published_at: 1784638400, reference_count: 2, created_at: 1784631405, updated_at: 1784638487, ...overrides, } } function createAgentReference( overrides: Partial = {}, ): SkillReferenceResponse { return { agent_id: 'agent-1', agent_icon: '🤖', agent_icon_background: '#EFF6FF', agent_icon_type: 'emoji', app_id: 'app-1', display_name: 'Support Agent', name: 'support-agent', type: 'agent', ...overrides, } } function createTestQueryClient() { return new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, }) } function renderSkillsPage(queryClient = createTestQueryClient()) { return render( , ) } describe('SkillsPage', () => { beforeEach(() => { vi.clearAllMocks() mocks.queryState.keyword = '' mocks.queryState.tag = [] mocks.skills = [createSkill()] mocks.skillPages = [mocks.skills] mocks.skillReferences = [createAgentReference()] mocks.genericTags = [ { binding_count: '2', id: 'tag-support', name: 'support', type: 'skill' }, { binding_count: '1', id: 'tag-sales', name: 'sales', type: 'skill' }, ] mocks.tags = [ { count: 2, tag: 'support' }, { count: 1, tag: 'sales' }, ] mocks.skillsKey.mockImplementation((options) => ['skills', options]) mocks.tagsKey.mockImplementation((options) => ['skill-tags', options]) mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey: ['skills', options], queryFn: async ({ pageParam }: { pageParam: unknown }) => { const page = Number(pageParam) return { data: mocks.skillPages[page - 1] ?? [], has_more: page < mocks.skillPages.length, page, total: mocks.skillPages.flat().length, } }, getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) mocks.tagsQueryOptions.mockImplementation((options) => ({ queryKey: ['skill-tags', options], queryFn: async () => ({ data: mocks.tags, }), })) mocks.genericTagsQueryOptions.mockImplementation((options) => ({ queryKey: ['tags', options], queryFn: async () => mocks.genericTags, })) mocks.skillReferencesQueryOptions.mockImplementation((options) => ({ queryKey: ['skill-references', options], queryFn: async () => ({ data: mocks.skillReferences, }), })) mocks.createSkillMutationFn.mockResolvedValue(createSkill({ id: 'created-skill' })) mocks.importSkillMutationFn.mockResolvedValue(createSkill({ id: 'imported-skill' })) mocks.duplicateSkillMutationFn.mockResolvedValue(createSkill({ id: 'duplicated-skill' })) mocks.exportSkillArchiveBlob.mockResolvedValue(new Blob(['skill archive'])) mocks.deleteSkillMutationFn.mockResolvedValue({ deleted: true, id: 'skill-1', }) }) it('exposes loading as a status without presenting skeletons as skill items', () => { mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey: ['skills-pending', options], queryFn: () => new Promise(() => {}), getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) renderSkillsPage() const skillRegion = screen.getByRole('region', { name: 'skill.skillManagement.listLabel', }) expect(skillRegion).toHaveAttribute('aria-busy', 'true') expect(within(skillRegion).getByRole('status')).toHaveTextContent('common.loading') expect(within(skillRegion).queryByRole('list')).not.toBeInTheDocument() expect(within(skillRegion).queryByRole('listitem')).not.toBeInTheDocument() }) it('exposes a failed skill request as an alert without a stale list', async () => { mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey: ['skills-error', options], queryFn: () => Promise.reject(new Error('skills request failed')), getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) renderSkillsPage() const skillRegion = screen.getByRole('region', { name: 'skill.skillManagement.listLabel', }) expect(await within(skillRegion).findByRole('alert')).toHaveTextContent( 'skill.skillManagement.loadingError', ) expect( within(skillRegion).getByRole('button', { name: 'common.operation.retry' }), ).toBeInTheDocument() expect(within(skillRegion).queryByRole('list')).not.toBeInTheDocument() }) it('replaces a cached empty state with a retryable error when refetch fails', async () => { const user = userEvent.setup() const queryKey = ['skills-cached-empty-refetch-error'] let requestCount = 0 mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey, queryFn: () => { requestCount += 1 return Promise.reject(new Error('skills refetch failed')) }, getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) const queryClient = createTestQueryClient() queryClient.setQueryData(queryKey, { pages: [{ data: [], has_more: false, page: 1, total: 0 }], pageParams: [1], }) renderSkillsPage(queryClient) const skillRegion = screen.getByRole('region', { name: 'skill.skillManagement.listLabel', }) const error = await within(skillRegion).findByRole('alert') expect(error).toHaveTextContent('skill.skillManagement.loadingError') expect(within(skillRegion).queryByText('skill.skillManagement.empty')).not.toBeInTheDocument() expect( within(skillRegion).queryByRole('button', { name: 'skill.skillManagement.emptyAction.createTitle', }), ).not.toBeInTheDocument() expect(within(skillRegion).queryByRole('list')).not.toBeInTheDocument() await user.click(within(error).getByRole('button', { name: 'common.operation.retry' })) await waitFor(() => { expect(requestCount).toBe(2) }) }) it('renders skills with tags, reference count, and detail links', async () => { renderSkillsPage() const skillLink = await screen.findByRole('link', { name: /Refund approval/ }) const skillList = screen.getByRole('list') expect(screen.getByRole('listitem', { name: 'Refund approval' })).toBeInTheDocument() expect(skillList).toContainElement(skillLink) expect(skillLink).toHaveAttribute('href', '/skills/skill-1') expect(skillLink).toHaveAccessibleDescription('Handle refund requests.') expect(within(skillLink).queryByRole('button')).not.toBeInTheDocument() expect(screen.getByText('refund-approval')).toBeInTheDocument() expect(screen.getByText('Handle refund requests.')).toBeInTheDocument() expect(screen.getByText('support')).toBeInTheDocument() expect( screen.getByText('skill.skillManagement.referenceCount_other:{"count":2}'), ).toBeInTheDocument() expect( screen.getByText('skill.skillManagement.publishedAt:{"time":"2 hours ago"}'), ).toBeInTheDocument() }) it('tabs from the card More action to its tag trigger', async () => { const user = userEvent.setup() renderSkillsPage() const skillLink = await screen.findByRole('link', { name: /Refund approval/ }) const moreButton = screen.getByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }) const tagTrigger = screen.getByRole('button', { name: 'support' }) skillLink.focus() await user.tab() expect(moreButton).toHaveFocus() await user.tab() expect(tagTrigger).toHaveFocus() }) it('renders draft update time as relative time', async () => { mocks.skills = [createSkill({ latest_published_version_id: null, latest_published_at: null })] mocks.skillPages = [mocks.skills] renderSkillsPage() const skillLink = await screen.findByRole('link', { name: 'Refund approval' }) expect(skillLink).toHaveAccessibleDescription( 'skill.skillManagement.draft Handle refund requests.', ) expect( screen.getByText('skill.skillManagement.editedAt:{"time":"2 hours ago"}'), ).toBeInTheDocument() }) it('shows guidance instead of a persisted editor placeholder when description is empty', async () => { mocks.skills = [createSkill({ description: '' })] mocks.skillPages = [mocks.skills] renderSkillsPage() expect(await screen.findByText('skill.skillManagement.noDescription')).toBeInTheDocument() }) it('passes keyword and selected tags to the list query', async () => { mocks.queryState.keyword = 'refund' mocks.queryState.tag = ['support'] renderSkillsPage() await waitFor(() => { const queryOptions = mocks.skillsQueryOptions.mock.lastCall?.[0] expect(queryOptions?.input(1)).toEqual({ query: { keyword: 'refund', limit: 20, page: 1, tag: ['support'], }, }) }) }) it('uses the localized Skills search hint as the search placeholder', async () => { renderSkillsPage() expect( await screen.findByRole('searchbox', { name: 'skill.skillManagement.searchLabel', }), ).toHaveAttribute('placeholder', 'skill.skillManagement.searchPlaceholder') }) it('clears stale tag names from the URL-backed filter state', async () => { mocks.queryState.tag = ['renamed-tag'] renderSkillsPage() await waitFor(() => { expect(mocks.queryState.tag).toEqual([]) }) await waitFor(() => { const queryOptions = mocks.skillsQueryOptions.mock.lastCall?.[0] expect(queryOptions?.input(1)).toEqual({ query: { limit: 20, page: 1, }, }) }) }) it('loads the next skill page when the list scrolls near the bottom', async () => { const firstPageSkills = Array.from({ length: 20 }, (_, index) => createSkill({ id: `skill-${index + 1}`, name: `skill-${index + 1}`, display_name: `Skill ${index + 1}`, }), ) const nextPageSkill = createSkill({ id: 'skill-21', name: 'skill-21', display_name: 'Skill 21', }) let resolveNextPage: | ((page: { data: SkillResponse[]; has_more: boolean; page: number; total: number }) => void) | undefined mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey: ['skills-deferred-next-page', options], queryFn: async ({ pageParam }: { pageParam: unknown }) => { if (Number(pageParam) === 1) { return { data: firstPageSkills, has_more: true, page: 1, total: 21, } } return new Promise((resolve) => { resolveNextPage = resolve }) }, getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) renderSkillsPage() const skillList = await screen.findByRole('region', { name: 'skill.skillManagement.listLabel', }) await screen.findByRole('heading', { name: 'Skill 1' }) expect(within(skillList).getAllByRole('listitem')).toHaveLength(20) const scrollViewport = skillList.parentElement?.parentElement expect(scrollViewport).not.toBeNull() Object.defineProperties(scrollViewport!, { clientHeight: { configurable: true, value: 600 }, scrollHeight: { configurable: true, value: 1200 }, scrollTop: { configurable: true, value: 560 }, }) fireEvent.scroll(scrollViewport!) const paginationLoading = await within(skillList).findByRole('status') expect(paginationLoading).toHaveTextContent('common.loading') expect(paginationLoading).toBeVisible() expect(within(skillList).getAllByRole('listitem')).toHaveLength(20) resolveNextPage?.({ data: [nextPageSkill], has_more: false, page: 2, total: 21, }) expect(await screen.findByRole('heading', { name: 'Skill 21' })).toBeInTheDocument() expect(within(skillList).getAllByRole('listitem')).toHaveLength(21) expect(mocks.skillsQueryOptions.mock.lastCall?.[0].input(2)).toEqual({ query: { limit: 20, page: 2, }, }) }) it('waits for a cached first page to refetch before auto-loading the next page', async () => { const queryKey = ['skills-cached-refetch'] const staleFirstPage = Array.from({ length: 20 }, (_, index) => createSkill({ id: `stale-skill-${index + 1}`, name: `stale-skill-${index + 1}`, display_name: `Stale Skill ${index + 1}`, }), ) const freshFirstPage = staleFirstPage.map((skill, index) => createSkill({ ...skill, display_name: `Fresh Skill ${index + 1}`, }), ) let resolveFirstPageRefetch: | ((page: { data: SkillResponse[]; has_more: boolean; page: number; total: number }) => void) | undefined let nextPageRequestCount = 0 mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey, queryFn: ({ pageParam }: { pageParam: unknown }) => { if (Number(pageParam) === 1) { return new Promise((resolve) => { resolveFirstPageRefetch = resolve }) } nextPageRequestCount += 1 return Promise.resolve({ data: [createSkill({ id: 'skill-21', name: 'skill-21', display_name: 'Fresh Skill 21' })], has_more: false, page: 2, total: 21, }) }, getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) const queryClient = createTestQueryClient() queryClient.setQueryData(queryKey, { pages: [ { data: staleFirstPage, has_more: true, page: 1, total: 21, }, ], pageParams: [1], }) renderSkillsPage(queryClient) const skillRegion = screen.getByRole('region', { name: 'skill.skillManagement.listLabel', }) expect(await screen.findByRole('heading', { name: 'Stale Skill 1' })).toBeInTheDocument() const scrollViewport = skillRegion.parentElement?.parentElement expect(scrollViewport).not.toBeNull() Object.defineProperties(scrollViewport!, { clientHeight: { configurable: true, value: 600 }, scrollHeight: { configurable: true, value: 1200 }, scrollTop: { configurable: true, value: 560 }, }) fireEvent.scroll(scrollViewport!) expect(nextPageRequestCount).toBe(0) expect(screen.getByRole('heading', { name: 'Stale Skill 1' })).toBeInTheDocument() resolveFirstPageRefetch?.({ data: freshFirstPage, has_more: true, page: 1, total: 21, }) expect(await screen.findByRole('heading', { name: 'Fresh Skill 1' })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: 'Fresh Skill 21' })).toBeInTheDocument() expect(nextPageRequestCount).toBe(1) }) it('keeps loaded skills visible when the next page fails and exposes retry', async () => { const user = userEvent.setup() const firstPageSkills = Array.from({ length: 20 }, (_, index) => createSkill({ id: `skill-${index + 1}`, name: `skill-${index + 1}`, display_name: `Skill ${index + 1}`, }), ) let nextPageRequestCount = 0 let resolveRetry: | ((page: { data: SkillResponse[]; has_more: boolean; page: number; total: number }) => void) | undefined mocks.skillsQueryOptions.mockImplementation((options) => ({ queryKey: ['skills-next-page-error', options], queryFn: async ({ pageParam }: { pageParam: unknown }) => { if (Number(pageParam) === 1) { return { data: firstPageSkills, has_more: true, page: 1, total: 21, } } nextPageRequestCount += 1 if (nextPageRequestCount === 1) throw new Error('next skill page failed') return new Promise((resolve) => { resolveRetry = resolve }) }, getNextPageParam: options.getNextPageParam, initialPageParam: options.initialPageParam, })) renderSkillsPage() const skillRegion = await screen.findByRole('region', { name: 'skill.skillManagement.listLabel', }) await screen.findByRole('heading', { name: 'Skill 1' }) const scrollViewport = skillRegion.parentElement?.parentElement expect(scrollViewport).not.toBeNull() Object.defineProperties(scrollViewport!, { clientHeight: { configurable: true, value: 600 }, scrollHeight: { configurable: true, value: 600 }, scrollTop: { configurable: true, value: 0 }, }) fireEvent.scroll(scrollViewport!) const paginationError = await within(skillRegion).findByRole('alert') expect(within(skillRegion).getAllByRole('listitem')).toHaveLength(20) expect(paginationError).toHaveTextContent('skill.skillManagement.loadingError') expect(nextPageRequestCount).toBe(1) const retryButton = within(paginationError).getByRole('button', { name: 'common.operation.retry', }) await user.click(retryButton) await waitFor(() => { expect(nextPageRequestCount).toBe(2) }) expect(retryButton).toHaveAttribute('aria-disabled', 'true') expect(retryButton).toHaveFocus() expect(within(skillRegion).queryByRole('status')).not.toBeInTheDocument() expect(within(skillRegion).getAllByRole('listitem')).toHaveLength(20) resolveRetry?.({ data: [createSkill({ id: 'skill-21', name: 'skill-21', display_name: 'Recovered Skill 21' })], has_more: false, page: 2, total: 21, }) expect(await screen.findByRole('heading', { name: 'Recovered Skill 21' })).toBeInTheDocument() expect(within(skillRegion).getAllByRole('listitem')).toHaveLength(21) }) it('creates a placeholder skill and navigates to its detail page', async () => { const user = userEvent.setup() const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') renderSkillsPage() await user.click(await screen.findByRole('button', { name: 'skill.skillManagement.create' })) await waitFor(() => { expect(mocks.createSkillMutationFn).toHaveBeenCalledWith( { body: {}, }, expect.anything(), ) }) expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.createSuccess') expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'query' }] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'infinite' }] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skill-tags', { type: 'query' }], }) expect(mocks.push).toHaveBeenCalledWith('/skills/created-skill') invalidateQueries.mockRestore() }) it('explains when the workspace skill limit blocks draft creation', async () => { const user = userEvent.setup() mocks.createSkillMutationFn.mockRejectedValueOnce({ code: 'skill_limit_exceeded' }) renderSkillsPage() await user.click(await screen.findByRole('button', { name: 'skill.skillManagement.create' })) await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('skill.skillManagement.errors.workspaceLimit') }) }) it('imports a package file and navigates to the imported skill', async () => { const user = userEvent.setup() const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') const { container } = renderSkillsPage() const fileInput = container.querySelector('input[type="file"]') expect(fileInput).not.toBeNull() const file = new File(['skill'], 'refund.skill', { type: 'application/zip' }) await user.upload(fileInput!, file) await waitFor(() => { expect(mocks.importSkillMutationFn).toHaveBeenCalledWith( { body: { file, }, }, expect.anything(), ) }) expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.importSuccess') expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'query' }] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'infinite' }] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skill-tags', { type: 'query' }], }) expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill') invalidateQueries.mockRestore() }) it.each([ { error: { data: { body: { code: 'skill_name_conflict', details: { name: 'refund-approval' }, }, }, }, message: 'skill.skillManagement.errors.nameConflict:{"name":"refund-approval"}', }, { error: { code: 'skill_limit_exceeded' }, message: 'skill.skillManagement.errors.workspaceLimit', }, { error: { code: 'missing_skill_md' }, message: 'skill.skillManagement.errors.missingSkillMd', }, { error: { message: 'Skill package must contain SKILL.md' }, message: 'skill.skillManagement.errors.missingSkillMd', }, { error: { message: 'Skill name "refund-approval" already exists' }, message: 'skill.skillManagement.errors.nameConflict:{"name":"refund-approval"}', }, ])('explains import errors for $error.code', async ({ error, message }) => { const user = userEvent.setup() mocks.importSkillMutationFn.mockRejectedValueOnce(error) const { container } = renderSkillsPage() const fileInput = container.querySelector('input[type="file"]') const file = new File(['skill'], 'refund.skill', { type: 'application/zip' }) await user.upload(fileInput!, file) await waitFor(() => { expect(toast.error).toHaveBeenCalledWith(message) }) }) it('explains an import error returned as a Response body', async () => { const user = userEvent.setup() mocks.importSkillMutationFn.mockRejectedValueOnce( new Response(JSON.stringify({ message: 'Skill package must contain SKILL.md' }), { status: 400, }), ) const { container } = renderSkillsPage() const fileInput = container.querySelector('input[type="file"]') await user.upload(fileInput!, new File(['skill'], 'refund.skill', { type: 'application/zip' })) await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('skill.skillManagement.errors.missingSkillMd') }) }) it('duplicates a skill from the card action menu', async () => { const user = userEvent.setup() renderSkillsPage() await user.click( await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }), ) await user.click(await screen.findByText('common.operation.duplicate')) await waitFor(() => { expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith( { params: { skill_id: 'skill-1', }, }, expect.anything(), ) }) expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess') }) it('exports a published skill from the card action menu', async () => { const user = userEvent.setup() renderSkillsPage() await user.click( await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }), ) await user.click(await screen.findByText('common.operation.export')) await waitFor(() => { expect(mocks.exportSkillArchiveBlob).toHaveBeenCalledWith('skill-1') }) expect(mocks.downloadBlob).toHaveBeenCalledWith({ data: expect.any(Blob), fileName: 'refund-approval.zip', }) }) it('does not show export for an unpublished skill', async () => { const user = userEvent.setup() mocks.skills = [createSkill({ latest_published_version_id: null })] mocks.skillPages = [mocks.skills] renderSkillsPage() await user.click( await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }), ) expect(screen.queryByText('common.operation.export')).not.toBeInTheDocument() }) it('confirms deletion with the skill name and refreshes list data', async () => { const user = userEvent.setup() renderSkillsPage() await user.click( await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }), ) await user.click(await screen.findByText('common.operation.delete')) const dialog = await screen.findByRole('alertdialog') expect( within(dialog).getByText( 'skill.skillManagement.deleteDialog.referencedDescription_other:{"count":2}', ), ).toBeInTheDocument() expect(await within(dialog).findByText('Support Agent')).toBeInTheDocument() expect(within(dialog).getByRole('link', { name: /Support Agent/ })).toHaveAttribute( 'target', '_blank', ) expect(within(dialog).getByTestId('skill-delete-reference-list')).toBeInTheDocument() await user.type( within(dialog).getByPlaceholderText( 'skill.skillManagement.deleteDialog.confirmInputPlaceholder', ), 'Refund approval', ) await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' })) await waitFor(() => { expect(mocks.deleteSkillMutationFn).toHaveBeenCalledWith( { body: { confirmation_name: 'Refund approval', }, params: { skill_id: 'skill-1', }, }, expect.anything(), ) }) expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.deleteSuccess') }) it('loads references in the delete confirmation when the list reference count is stale', async () => { const user = userEvent.setup() mocks.skills = [createSkill({ reference_count: 0 })] mocks.skillPages = [mocks.skills] mocks.skillReferences = [ createAgentReference({ agent_id: 'agent-stale-reference', display_name: 'Support Agent From References API', name: 'support-agent-from-references-api', }), ] renderSkillsPage() await user.click( await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }), ) await user.click(await screen.findByText('common.operation.delete')) const dialog = await screen.findByRole('alertdialog') expect(await within(dialog).findByText('Support Agent From References API')).toBeInTheDocument() expect( within(dialog).getByText( 'skill.skillManagement.deleteDialog.referencedDescription_one:{"count":1}', ), ).toBeInTheDocument() expect(mocks.skillReferencesQueryOptions).toHaveBeenCalledWith( expect.objectContaining({ input: { params: { skill_id: 'skill-1', }, }, }), ) }) it('keeps deletion disabled while cached references refresh', async () => { const user = userEvent.setup() let referenceRequestCount = 0 let shouldHangReferenceRequest = false mocks.skills = [createSkill({ reference_count: 0 })] mocks.skillPages = [mocks.skills] mocks.skillReferencesQueryOptions.mockImplementation((options) => ({ queryKey: ['skill-references-pending', options], queryFn: () => { referenceRequestCount += 1 if (!shouldHangReferenceRequest) return Promise.resolve({ data: [] }) return new Promise(() => {}) }, })) renderSkillsPage() const moreButton = await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }) await user.click(moreButton) await user.click(await screen.findByText('common.operation.delete')) let dialog = await screen.findByRole('alertdialog') await waitFor(() => { expect(within(dialog).getByRole('button', { name: 'common.operation.delete' })).toBeEnabled() }) const initialRequestCount = referenceRequestCount shouldHangReferenceRequest = true await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) await user.click(moreButton) await user.click(await screen.findByText('common.operation.delete')) dialog = await screen.findByRole('alertdialog') expect( within(dialog).getByRole('button', { name: 'common.operation.delete', }), ).toBeDisabled() await waitFor(() => { expect(referenceRequestCount).toBeGreaterThan(initialRequestCount) }) }) it('collapses long reference lists in the delete confirmation', async () => { const user = userEvent.setup() mocks.skillReferences = Array.from({ length: 7 }, (_, index) => createAgentReference({ agent_id: `agent-${index}`, display_name: `Support Agent ${index + 1}`, name: `support-agent-${index + 1}`, }), ) renderSkillsPage() await user.click( await screen.findByRole('button', { name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}', }), ) await user.click(await screen.findByText('common.operation.delete')) const dialog = await screen.findByRole('alertdialog') expect(await within(dialog).findByText('Support Agent 5')).toBeInTheDocument() expect(within(dialog).queryByText('Support Agent 6')).not.toBeInTheDocument() expect(within(dialog).getByTestId('skill-delete-reference-list')).not.toHaveAttribute( 'data-scrollable', 'true', ) await user.click( within(dialog).getByRole('button', { name: 'skill.skillManagement.detail.showMoreReferences:{"count":2}', }), ) expect(within(dialog).getByText('Support Agent 6')).toBeInTheDocument() expect(within(dialog).getByText('Support Agent 7')).toBeInTheDocument() expect(within(dialog).getByTestId('skill-delete-reference-list')).toHaveAttribute( 'data-scrollable', 'true', ) expect(within(dialog).getByTestId('skill-delete-reference-list')).toHaveClass( 'max-h-[240px]', 'overflow-y-auto', ) }) it('shows the empty-search state without create or import actions', async () => { mocks.queryState.keyword = 'missing' mocks.skills = [] mocks.skillPages = [[]] renderSkillsPage() const skillRegion = screen.getByRole('region', { name: 'skill.skillManagement.listLabel', }) const emptySearchTitle = await within(skillRegion).findByText( 'skill.skillManagement.emptySearch', ) expect(emptySearchTitle.closest('[role="status"]')).toBeInTheDocument() expect( screen.queryByText('skill.skillManagement.emptyAction.createTitle'), ).not.toBeInTheDocument() expect( screen.queryByText('skill.skillManagement.emptyAction.importTitle'), ).not.toBeInTheDocument() }) })