mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
test: split skill detail page suite (#41799)
This commit is contained in:
parent
9e68c45058
commit
0d8ad27b14
295
web/features/skills/__tests__/detail-page-autosave.spec.tsx
Normal file
295
web/features/skills/__tests__/detail-page-autosave.spec.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
createSkillDetail,
|
||||
getLiveMarkdownEditor,
|
||||
getMocks,
|
||||
getSourceEditor,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage autosave', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('sends only one autosave request while the first save is pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.saveDraftFileMutationFn.mockImplementation(() => new Promise(() => undefined))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nNew instructions')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalled()
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('saves dirty content once when the editor unmounts before autosave', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nNew instructions')
|
||||
unmount()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads the latest draft before allowing autosave after a conflict', async () => {
|
||||
const user = userEvent.setup()
|
||||
const latestDetail = createSkillDetail({
|
||||
updated_at: 1784638499,
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Guide for debugging failing GitHub Actions workflows.\nmetadata:\n display-name: Untitled skill\n---\n# Changed from another tab\n',
|
||||
tool_file_id: null,
|
||||
size: 180,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const conflict = {
|
||||
code: 'skill_conflict',
|
||||
details: {
|
||||
current_file_hash: 'hash-2',
|
||||
current_updated_at: 1784638499,
|
||||
current_file_content: latestDetail.files?.[0]?.content ?? '',
|
||||
expected_updated_at: 1784638487,
|
||||
},
|
||||
}
|
||||
mocks.saveDraftFileMutationFn.mockRejectedValueOnce(conflict)
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nMy tab changes')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.skillDetailGetFn).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/skill\.skillManagement\.detail\.saveConflictStatus/),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'skill.skillManagement.detail.saveConflictCancel' }),
|
||||
).not.toBeInTheDocument()
|
||||
mocks.skillDetail = latestDetail
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.saveConflictReload' }),
|
||||
)
|
||||
expect(getSourceEditor()).toHaveValue(latestDetail.files?.[0]?.content ?? '')
|
||||
|
||||
await user.type(getSourceEditor(), '\nMy changes after loading the latest draft')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(2)
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringContaining('My changes after loading the latest draft'),
|
||||
expected_updated_at: latestDetail.updated_at,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}, 10000)
|
||||
|
||||
it('discards live editor content when loading the latest draft after a conflict', async () => {
|
||||
const user = userEvent.setup()
|
||||
const latestDetail = createSkillDetail({
|
||||
updated_at: 1784638499,
|
||||
files: [
|
||||
{
|
||||
...createSkillDetail().files![0]!,
|
||||
content:
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Guide for debugging failing GitHub Actions workflows.\nmetadata:\n display-name: Untitled skill\n---\n# Latest content from another tab\n',
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.saveDraftFileMutationFn.mockRejectedValueOnce({
|
||||
code: 'skill_conflict',
|
||||
details: {
|
||||
current_file_content: latestDetail.files?.[0]?.content ?? '',
|
||||
current_updated_at: latestDetail.updated_at,
|
||||
expected_updated_at: 1784638487,
|
||||
},
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
const livePreview = await screen.findByRole('textbox', {
|
||||
name: 'skill.skillManagement.detail.referenceFiles.livePlaceholder',
|
||||
})
|
||||
await user.click(livePreview)
|
||||
const liveEditor = await waitFor(() => getLiveMarkdownEditor())
|
||||
liveEditor.textContent = '# Local unsaved content'
|
||||
fireEvent.input(liveEditor)
|
||||
|
||||
expect(await screen.findByRole('alertdialog', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
mocks.skillDetail = latestDetail
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.saveConflictReload' }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
const reloadedLivePreview = screen.getByRole('textbox', {
|
||||
name: 'skill.skillManagement.detail.referenceFiles.livePlaceholder',
|
||||
})
|
||||
expect(reloadedLivePreview).toHaveTextContent('Latest content from another tab')
|
||||
expect(reloadedLivePreview).not.toHaveTextContent('Local unsaved content')
|
||||
})
|
||||
}, 10000)
|
||||
|
||||
it('uses conflict details from response errors without retrying autosave', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.saveDraftFileMutationFn.mockRejectedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 'skill_conflict',
|
||||
message: 'skill has been modified by another user',
|
||||
details: {
|
||||
current_file_hash: 'hash-2',
|
||||
current_updated_at: 1784638499,
|
||||
expected_updated_at: 1784638487,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 409,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
)
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nMy response error changes')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/skill\.skillManagement\.detail\.saveConflictStatus/),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
}, 10000)
|
||||
|
||||
it('shows a save failure when conflict recovery cannot determine the latest timestamp', async () => {
|
||||
const user = userEvent.setup()
|
||||
const conflict = new Error('skill has been modified by another user') as Error & {
|
||||
code: string
|
||||
details: {
|
||||
current_file_hash: string
|
||||
}
|
||||
}
|
||||
conflict.code = 'skill_conflict'
|
||||
conflict.details = {
|
||||
current_file_hash: 'hash-2',
|
||||
}
|
||||
mocks.saveDraftFileMutationFn.mockRejectedValueOnce(conflict)
|
||||
mocks.skillDetailGetFn.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nMy unrecoverable conflict changes')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.detail.saveFailed')
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
)
|
||||
expect(screen.getByText(/skill\.skillManagement\.detail\.saveFailed/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stops autosaving unchanged content after a non-conflict save failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.saveDraftFileMutationFn.mockRejectedValue(new Error('save failed'))
|
||||
|
||||
renderSkillDetailPage()
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
|
||||
const sourceEditor = getSourceEditor()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
fireEvent.change(sourceEditor, {
|
||||
target: { value: `${sourceEditor.value}\nMy failing save changes` },
|
||||
})
|
||||
await act(() => vi.advanceTimersByTimeAsync(1000))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.detail.saveFailed')
|
||||
expect(screen.getByText(/skill\.skillManagement\.detail\.saveFailed/)).toBeInTheDocument()
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(2200))
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.change(sourceEditor, {
|
||||
target: { value: `${sourceEditor.value}\nRetry after another edit` },
|
||||
})
|
||||
await act(() => vi.advanceTimersByTimeAsync(1000))
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}, 10000)
|
||||
})
|
||||
897
web/features/skills/__tests__/detail-page-builder.spec.tsx
Normal file
897
web/features/skills/__tests__/detail-page-builder.spec.tsx
Normal file
@ -0,0 +1,897 @@
|
||||
import type { SkillDetailResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
createDefaultSkillDraftDetail,
|
||||
createSkillDetail,
|
||||
getBuilderAttachmentInput,
|
||||
getMocks,
|
||||
getSourceEditor,
|
||||
openRootCreateMenu,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage builder', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('shows Skill manifest placeholders for an empty draft', async () => {
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByPlaceholderText('skill.skillManagement.detail.skillNamePlaceholder'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.skillDescriptionPlaceholder'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Describe what this Skill does, when an Agent should use it, and any step-by-step instructions it must follow.',
|
||||
),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the empty draft marker after Builder fills only the Skill description', async () => {
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail({
|
||||
description: 'Generate consistent character illustrations from a short prompt.',
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: character-illustration\ndescription: Generate consistent character illustrations from a short prompt.\nmetadata:\n display-name: Character illustration\n---\n\n<!-- dify-skill-empty-draft -->\n',
|
||||
tool_file_id: null,
|
||||
size: 190,
|
||||
hash: 'hash-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByDisplayValue(
|
||||
'Generate consistent character illustrations from a short prompt.',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('<!-- dify-skill-empty-draft -->')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats a newly created empty Skill draft as Builder creation mode', async () => {
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail({
|
||||
description: '',
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '<!-- dify-skill-empty-draft -->\n',
|
||||
tool_file_id: null,
|
||||
size: 32,
|
||||
hash: 'hash-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.builder.promptTitle'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('skill.skillManagement.detail.builder.editIntro'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves the collapsed Skill Builder entry into the file tab header', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.close',
|
||||
}),
|
||||
)
|
||||
|
||||
const openBuilderButton = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.open',
|
||||
})
|
||||
expect(openBuilderButton.closest('main')).toBeInTheDocument()
|
||||
|
||||
await user.click(openBuilderButton)
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.close',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends uploaded Skill Builder attachments without requiring typed text', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.builder.editIntro'),
|
||||
).toBeInTheDocument()
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['# Guide'], 'guide.md', {
|
||||
type: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByText('guide.md')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByText('guide.md')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('skill.skillManagement.detail.builder.editIntro'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skillId: 'skill-1',
|
||||
message: 'skill.skillManagement.detail.builder.attachmentOnlyMessage',
|
||||
attachments: [
|
||||
{
|
||||
mime_type: 'text/markdown',
|
||||
name: 'guide.md',
|
||||
size: 10,
|
||||
tool_file_id: 'tool-file-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a Skill Builder attachment request with the original attachment', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData }) => {
|
||||
onData?.('Used the guide.', true, {})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['# Guide'], 'guide.md', {
|
||||
type: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
}),
|
||||
)
|
||||
await screen.findByText('Used the guide.')
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.retryResponse',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mocks.sendSkillAssistMessage).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.sendSkillAssistMessage.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
{
|
||||
mime_type: 'text/markdown',
|
||||
name: 'guide.md',
|
||||
size: 10,
|
||||
tool_file_id: 'tool-file-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('removes uploaded Skill Builder attachments before sending', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['# Guide'], 'guide.md', {
|
||||
type: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByText('guide.md')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.removeAttachment:{"name":"guide.md"}',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.queryByText('guide.md')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens the Skill Builder attachment picker from the toolbar button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
const clickSpy = vi.spyOn(attachmentInput!, 'click')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.attach',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not send the Skill Builder prompt while an attachment is uploading', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.uploadSkillFile.mockImplementation(() => new Promise(() => undefined))
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['# Guide'], 'guide.md', {
|
||||
type: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const promptInput = screen.getByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
await user.type(promptInput, 'Use the attached guide{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
expect(promptInput).toHaveValue('Use the attached guide')
|
||||
}, 10000)
|
||||
|
||||
it('ignores an in-flight attachment after restarting Skill Builder', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveUpload!: (file: {
|
||||
id: string
|
||||
mime_type: string
|
||||
name: string
|
||||
size: number
|
||||
}) => void
|
||||
mocks.uploadSkillFile.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
}),
|
||||
)
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['# Guide'], 'guide.md', {
|
||||
type: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(mocks.uploadSkillFile).toHaveBeenCalledOnce())
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.restart',
|
||||
}),
|
||||
)
|
||||
expect(attachmentInput).toHaveValue('')
|
||||
|
||||
await act(async () => {
|
||||
resolveUpload({
|
||||
id: 'tool-file-1',
|
||||
mime_type: 'text/markdown',
|
||||
name: 'guide.md',
|
||||
size: 10,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('guide.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Skill Builder suggestions while an attachment is uploading', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.uploadSkillFile.mockImplementation(() => new Promise(() => undefined))
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['# Guide'], 'guide.md', {
|
||||
type: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const suggestion = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
})
|
||||
expect(suggestion).toBeDisabled()
|
||||
|
||||
await user.click(suggestion)
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send the Skill Builder prompt when Enter confirms IME composition', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
fireEvent.change(promptInput, { target: { value: 'ni' } })
|
||||
fireEvent.compositionStart(promptInput)
|
||||
fireEvent.keyDown(promptInput, { isComposing: true, key: 'Enter' })
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
expect(promptInput).toHaveValue('ni')
|
||||
})
|
||||
|
||||
it('keeps blocking Skill Builder Enter briefly after IME composition ends', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
fireEvent.change(promptInput, { target: { value: '你好' } })
|
||||
fireEvent.compositionStart(promptInput)
|
||||
fireEvent.compositionEnd(promptInput)
|
||||
fireEvent.keyDown(promptInput, { isComposing: false, key: 'Enter' })
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
expect(promptInput).toHaveValue('你好')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
fireEvent.keyDown(promptInput, { isComposing: false, key: 'Enter' })
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: '你好',
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends the Skill Builder prompt with Enter when IME composition is inactive', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
fireEvent.change(promptInput, { target: { value: 'Create a support triage skill' } })
|
||||
fireEvent.keyDown(promptInput, { isComposing: false, key: 'Enter' })
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Create a support triage skill',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks Skill Builder sends when no model is selected or available', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.defaultTextGenerationModel = undefined
|
||||
mocks.textGenerationModelList = []
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.placeholder',
|
||||
)
|
||||
const sendButton = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
})
|
||||
const suggestion = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
})
|
||||
|
||||
expect(sendButton).toBeDisabled()
|
||||
expect(suggestion).toBeDisabled()
|
||||
|
||||
await user.type(promptInput, 'Create a support triage skill{Enter}')
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
|
||||
expect(sendButton).toBeDisabled()
|
||||
}, 15000)
|
||||
|
||||
it('uploads image attachments in Skill Builder', async () => {
|
||||
const user = userEvent.setup({ applyAccept: false })
|
||||
mocks.uploadSkillFile.mockResolvedValue({
|
||||
id: 'tool-image-1',
|
||||
name: 'image.png',
|
||||
mime_type: 'image/png',
|
||||
size: 5,
|
||||
})
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
const attachmentInput = getBuilderAttachmentInput(container)
|
||||
expect(attachmentInput).not.toBeNull()
|
||||
|
||||
await user.upload(
|
||||
attachmentInput!,
|
||||
new File(['image'], 'image.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledWith(expect.any(File))
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.send',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByRole('img', { name: 'image.png' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an error and re-enables Skill Builder input when sending fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockRejectedValue(new Error('builder unavailable'))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const promptInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.placeholder',
|
||||
)
|
||||
await user.type(promptInput, 'Create a support triage skill{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('builder unavailable')
|
||||
})
|
||||
expect(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
|
||||
).toBeEnabled()
|
||||
}, 15000)
|
||||
|
||||
it('shows Skill Builder completion errors returned by the assistant stream', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted }) => {
|
||||
onCompleted?.(true, 'builder stream failed')
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('builder stream failed')
|
||||
})
|
||||
expect(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('replaces optimistic Skill Builder replies when the assistant stream returns an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onData, onError }) => {
|
||||
onData?.('已创建用于客户问题分级处理的 skill 草案', true, {
|
||||
messageId: 'assistant-message',
|
||||
})
|
||||
onError?.(
|
||||
'the Skill Authoring assistant could not apply its response',
|
||||
'skill_assistant_failed',
|
||||
)
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'the Skill Authoring assistant could not apply its response',
|
||||
)
|
||||
})
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
screen.getByText('the Skill Authoring assistant could not apply its response'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('已创建用于客户问题分级处理的 skill 草案')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends suggestion chips as Builder messages and blocks concurrent sends', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(() => new Promise<void>(() => undefined))
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.builder.thinking'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('0s')).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
|
||||
).toBeDisabled()
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates the selected editor from the Skill Builder detail event', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
const nextSkillMd =
|
||||
'---\nname: builder-updated-skill\ndescription: Updated by Skill Builder.\nmetadata:\n display-name: Builder Updated Skill\n---\n# Builder Updated Skill\n'
|
||||
const nextDetail = createDefaultSkillDraftDetail({
|
||||
name: 'builder-updated-skill',
|
||||
display_name: 'Builder Updated Skill',
|
||||
description: 'Updated by Skill Builder.',
|
||||
updated_at: 1784638490,
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: nextSkillMd,
|
||||
tool_file_id: null,
|
||||
size: nextSkillMd.length,
|
||||
hash: 'updated-hash-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData, onUnhandledEvent }) => {
|
||||
onData?.('Updated SKILL.md.', true, {})
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_detail_updated',
|
||||
detail: nextDetail,
|
||||
operations: [{ operation: 'upsert_text', path: 'SKILL.md' }],
|
||||
})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetPath: 'SKILL.md',
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
const currentSourceEditor = screen
|
||||
.getAllByRole('textbox')
|
||||
.find(
|
||||
(editor): editor is HTMLTextAreaElement =>
|
||||
editor instanceof HTMLTextAreaElement && editor.value.includes('Builder Updated Skill'),
|
||||
)
|
||||
expect(currentSourceEditor?.value).toContain('# Builder Updated Skill')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps assistant prose in the chat without using it as file content', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData }) => {
|
||||
onData?.('I can create that reference file.', true, {})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('I can create that reference file.')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /skill\.skillManagement\.detail\.builder\.thinking/,
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
screen.getByText('skill.skillManagement.detail.builder.thinkingUnavailable'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('0s')).toBeInTheDocument()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends Skill Builder follow-up suggestions after an assistant reply', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData, onUnhandledEvent }) => {
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_assistant_progress',
|
||||
stage: 'reading_draft',
|
||||
})
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_assistant_reasoning_chunk',
|
||||
reasoning: 'Inspecting the current skill draft.',
|
||||
})
|
||||
onData?.('Drafted the skill.', true, {})
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_assistant_suggestions',
|
||||
suggestions: ['Ask me about required inputs first'],
|
||||
})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
await screen.findByText('Drafted the skill.')
|
||||
expect(screen.queryByText('Inspecting the current skill draft.')).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /skill\.skillManagement\.detail\.builder\.thinking/,
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText('Inspecting the current skill draft.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('skill.skillManagement.detail.builder.progress.readingDraft'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Ask me about required inputs first',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Ask me about required inputs first',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Skill Builder progress steps when thinking content is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData, onUnhandledEvent }) => {
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_assistant_progress',
|
||||
stage: 'reading_draft',
|
||||
})
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_assistant_progress',
|
||||
stage: 'generating_plan',
|
||||
})
|
||||
onData?.('Drafted the skill.', true, {})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
await screen.findByText('Drafted the skill.')
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /skill\.skillManagement\.detail\.builder\.thinking/,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('skill.skillManagement.detail.builder.progress.readingDraft'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('skill.skillManagement.detail.builder.progress.generatingPlan'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('serializes editor autosave and file creation with the latest timestamp', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveAutosave!: (detail: SkillDetailResponse) => void
|
||||
const autosavePromise = new Promise<SkillDetailResponse>((resolve) => {
|
||||
resolveAutosave = resolve
|
||||
})
|
||||
const autosavedDetail = createSkillDetail({
|
||||
updated_at: 1784638490,
|
||||
})
|
||||
const createdDetail = createSkillDetail({
|
||||
updated_at: 1784638491,
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'notes.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '',
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.saveDraftFileMutationFn
|
||||
.mockImplementationOnce(() => autosavePromise)
|
||||
.mockResolvedValueOnce(createdDetail)
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nAutosave in progress')
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
|
||||
await openRootCreateMenu(user)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('skill.skillManagement.detail.createFile'),
|
||||
'notes.md{Enter}',
|
||||
)
|
||||
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveAutosave(autosavedDetail)
|
||||
await autosavePromise
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: 1784638490,
|
||||
operation: 'upsert_text',
|
||||
path: 'notes.md',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('updates non-SKILL files from the Skill Builder detail event', async () => {
|
||||
const user = userEvent.setup()
|
||||
const referenceFile = {
|
||||
id: 'file-2',
|
||||
path: 'references/refund-policy.md',
|
||||
kind: 'file' as const,
|
||||
storage: 'text' as const,
|
||||
mime_type: 'text/markdown',
|
||||
content: '# Refund Policy\n',
|
||||
tool_file_id: null,
|
||||
size: 16,
|
||||
hash: 'reference-hash-1',
|
||||
}
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
const nextDetail = createDefaultSkillDraftDetail({
|
||||
updated_at: 1784638490,
|
||||
files: [...createDefaultSkillDraftDetail().files!, referenceFile],
|
||||
})
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData, onUnhandledEvent }) => {
|
||||
onData?.('Created references/refund-policy.md.', true, {})
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_detail_updated',
|
||||
detail: nextDetail,
|
||||
operations: [{ operation: 'upsert_text', path: 'references/refund-policy.md' }],
|
||||
})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('references')).toBeInTheDocument()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
679
web/features/skills/__tests__/detail-page-clipboard.spec.tsx
Normal file
679
web/features/skills/__tests__/detail-page-clipboard.spec.tsx
Normal file
@ -0,0 +1,679 @@
|
||||
import { act, fireEvent, 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 {
|
||||
createDataTransfer,
|
||||
createSkillDetail,
|
||||
getFileTreeButton,
|
||||
getFileTreeContextRegion,
|
||||
getFileTreeItem,
|
||||
getMocks,
|
||||
openFileTreeActions,
|
||||
primaryModifier,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
function createSelectionSkillDetail() {
|
||||
return createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-readme',
|
||||
path: 'README.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# README',
|
||||
tool_file_id: null,
|
||||
size: 8,
|
||||
hash: 'hash-readme',
|
||||
},
|
||||
{
|
||||
id: 'file-example',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-example',
|
||||
},
|
||||
{
|
||||
id: 'directory-references',
|
||||
path: 'references',
|
||||
kind: 'directory',
|
||||
storage: 'text',
|
||||
mime_type: null,
|
||||
content: null,
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'hash-references',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('SkillDetailPage clipboard', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('copies the context-menu file with the displayed keyboard shortcut', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.contextMenu(getFileTreeItem('SKILL.md'), {
|
||||
button: 2,
|
||||
clientX: 120,
|
||||
clientY: 240,
|
||||
})
|
||||
await screen.findByText('common.operation.rename...')
|
||||
|
||||
const copyMenuItem = screen.getByRole('menuitem', {
|
||||
name: /skillManagement\.detail\.copyFile/,
|
||||
})
|
||||
copyMenuItem.addEventListener('keydown', (event) => event.stopPropagation())
|
||||
fireEvent.keyDown(copyMenuItem, {
|
||||
code: 'KeyC',
|
||||
key: 'c',
|
||||
...primaryModifier,
|
||||
})
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyContentSuccess',
|
||||
)
|
||||
})
|
||||
|
||||
it('cuts the context-menu file with the displayed keyboard shortcut', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.contextMenu(getFileTreeItem('SKILL.md'), {
|
||||
button: 2,
|
||||
clientX: 120,
|
||||
clientY: 240,
|
||||
})
|
||||
await screen.findByText('common.operation.rename...')
|
||||
|
||||
const cutMenuItem = screen.getByRole('menuitem', {
|
||||
name: /skill\.skillManagement\.detail\.cutFile/,
|
||||
})
|
||||
cutMenuItem.addEventListener('keydown', (event) => event.stopPropagation())
|
||||
fireEvent.keyDown(cutMenuItem, {
|
||||
code: 'KeyX',
|
||||
key: 'x',
|
||||
...primaryModifier,
|
||||
})
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('skill.skillManagement.detail.cutFileSuccess')
|
||||
})
|
||||
|
||||
it('copies a file with the keyboard shortcut and pastes it into the selected folder', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
kind: 'directory',
|
||||
path: 'scripts',
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.copy(getFileTreeButton('SKILL.md'))
|
||||
await user.click(screen.getByRole('button', { name: 'scripts' }))
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_text',
|
||||
path: 'scripts/SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('lets editable fields handle native copy even when page selection contains an empty draft marker', async () => {
|
||||
const user = userEvent.setup()
|
||||
const getSelectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
toString: () => '<!-- dify-skill-empty-draft -->',
|
||||
} as Selection)
|
||||
try {
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
const builderInput = screen.getByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
fireEvent.copy(builderInput)
|
||||
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalledWith('<!-- dify-skill-empty-draft -->')
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyContentSuccess',
|
||||
)
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyFileSuccess',
|
||||
)
|
||||
} finally {
|
||||
getSelectionSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let file-tree copy hotkeys override copying from the builder panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
|
||||
const builderControl = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.close',
|
||||
})
|
||||
fireEvent.keyDown(builderControl, {
|
||||
code: 'KeyC',
|
||||
key: 'c',
|
||||
...primaryModifier,
|
||||
})
|
||||
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyContentSuccess',
|
||||
)
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyFileSuccess',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens only the copied file after pasting it beside the source file', async () => {
|
||||
const user = userEvent.setup()
|
||||
const sourceFile = createSkillDetail().files![0]!
|
||||
const copiedFile = {
|
||||
...sourceFile,
|
||||
id: 'file-2',
|
||||
path: 'SKILL copy.md',
|
||||
hash: 'hash-2',
|
||||
}
|
||||
mocks.saveDraftFileMutationFn.mockImplementationOnce(async () => {
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
updated_at: 1784638490,
|
||||
files: [sourceFile, copiedFile],
|
||||
})
|
||||
return mocks.skillDetail
|
||||
})
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
mocks.skillDetailQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-detail'],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.copy(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_text',
|
||||
path: 'SKILL copy.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
await waitFor(() => {
|
||||
const editorMain = screen.getAllByRole('main').at(-1)
|
||||
if (!editorMain) throw new Error('file editor not found')
|
||||
expect(
|
||||
within(editorMain).getByRole('button', {
|
||||
name: 'SKILL copy.md',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes and retries a paste once when its skill timestamp is stale', async () => {
|
||||
const user = userEvent.setup()
|
||||
const sourceFile = createSkillDetail().files![0]!
|
||||
const latestDetail = createSkillDetail({
|
||||
updated_at: 1784638490,
|
||||
files: [sourceFile],
|
||||
})
|
||||
const copiedDetail = createSkillDetail({
|
||||
updated_at: 1784638491,
|
||||
files: [
|
||||
sourceFile,
|
||||
{
|
||||
...sourceFile,
|
||||
id: 'file-2',
|
||||
path: 'SKILL copy.md',
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
const conflict = new Error('skill has been modified by another user') as Error & {
|
||||
code: string
|
||||
details: {
|
||||
current_updated_at: number
|
||||
expected_updated_at: number
|
||||
}
|
||||
}
|
||||
conflict.code = 'skill_conflict'
|
||||
conflict.details = {
|
||||
current_updated_at: latestDetail.updated_at,
|
||||
expected_updated_at: 1784638487,
|
||||
}
|
||||
mocks.saveDraftFileMutationFn
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockImplementationOnce(async () => {
|
||||
mocks.skillDetail = copiedDetail
|
||||
return copiedDetail
|
||||
})
|
||||
mocks.skillDetailGetFn.mockResolvedValueOnce(latestDetail)
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
mocks.skillDetailQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-detail'],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.copy(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: latestDetail.updated_at,
|
||||
operation: 'upsert_text',
|
||||
path: 'SKILL copy.md',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mocks.toastError).not.toHaveBeenCalledWith('skill has been modified by another user')
|
||||
})
|
||||
|
||||
it('retries only the current file when a multi-file paste becomes stale', async () => {
|
||||
const user = userEvent.setup()
|
||||
const sourceFiles = [
|
||||
{
|
||||
...createSkillDetail().files![0]!,
|
||||
id: 'file-1',
|
||||
path: 'alpha.md',
|
||||
},
|
||||
{
|
||||
...createSkillDetail().files![0]!,
|
||||
id: 'file-2',
|
||||
path: 'beta.md',
|
||||
hash: 'hash-2',
|
||||
},
|
||||
]
|
||||
const copiedAlpha = {
|
||||
...sourceFiles[0]!,
|
||||
id: 'file-3',
|
||||
path: 'alpha copy.md',
|
||||
hash: 'hash-3',
|
||||
}
|
||||
const afterAlphaCopy = createSkillDetail({
|
||||
updated_at: 1784638488,
|
||||
files: [...sourceFiles, copiedAlpha],
|
||||
})
|
||||
const refreshedDetail = createSkillDetail({
|
||||
updated_at: 1784638490,
|
||||
files: [...sourceFiles, copiedAlpha],
|
||||
})
|
||||
const copiedBeta = {
|
||||
...sourceFiles[1]!,
|
||||
id: 'file-4',
|
||||
path: 'beta copy.md',
|
||||
hash: 'hash-4',
|
||||
}
|
||||
const afterBetaCopy = createSkillDetail({
|
||||
updated_at: 1784638491,
|
||||
files: [...sourceFiles, copiedAlpha, copiedBeta],
|
||||
})
|
||||
const conflict = new Error('skill has been modified by another user') as Error & {
|
||||
code: string
|
||||
}
|
||||
conflict.code = 'skill_conflict'
|
||||
|
||||
mocks.skillDetail = createSkillDetail({ files: sourceFiles })
|
||||
mocks.saveDraftFileMutationFn
|
||||
.mockResolvedValueOnce(afterAlphaCopy)
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce(afterBetaCopy)
|
||||
mocks.skillDetailGetFn.mockResolvedValueOnce(refreshedDetail)
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
mocks.skillDetailQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-detail'],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('alpha.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('alpha.md'))
|
||||
fireEvent.click(getFileTreeButton('beta.md'), primaryModifier)
|
||||
fireEvent.copy(getFileTreeButton('beta.md'))
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn.mock.calls.map(([request]) => request.body.path)).toEqual([
|
||||
'alpha copy.md',
|
||||
'beta copy.md',
|
||||
'beta copy.md',
|
||||
])
|
||||
expect(mocks.saveDraftFileMutationFn.mock.calls[2]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: refreshedDetail.updated_at,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('cuts a nested file and pastes it into the root after selecting the blank area', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'example.ts' }))
|
||||
fireEvent.cut(getFileTreeButton('scripts/example.ts'))
|
||||
const contextRegion = document.querySelector('[data-skill-file-tree-context-region]')
|
||||
if (!(contextRegion instanceof HTMLElement))
|
||||
throw new Error('file tree context region not found')
|
||||
fireEvent.contextMenu(contextRegion, {
|
||||
button: 2,
|
||||
clientX: 160,
|
||||
clientY: 520,
|
||||
})
|
||||
const rootMenuItem = (
|
||||
await screen.findByText('skill.skillManagement.detail.createFileMenu')
|
||||
).closest('[role="menuitem"]')
|
||||
if (!(rootMenuItem instanceof HTMLElement)) throw new Error('root menu item not found')
|
||||
fireEvent.keyDown(rootMenuItem, {
|
||||
code: 'KeyV',
|
||||
key: 'v',
|
||||
...primaryModifier,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'scripts/example.ts',
|
||||
target_path: 'example.ts',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a file from the file-list blank-area context menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const contextRegion = await waitFor(() => getFileTreeContextRegion())
|
||||
fireEvent.contextMenu(contextRegion, {
|
||||
button: 2,
|
||||
clientX: 160,
|
||||
clientY: 520,
|
||||
})
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('skill.skillManagement.detail.createFile'),
|
||||
'from-context.md{Enter}',
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_text',
|
||||
path: 'from-context.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the complete multi-selection into the dropped folder', async () => {
|
||||
mocks.skillDetail = createSelectionSkillDetail()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => expect(getFileTreeItem('SKILL.md')).toBeInTheDocument())
|
||||
const exampleFile = getFileTreeItem('scripts/example.ts')
|
||||
const targetFolder = getFileTreeItem('references')
|
||||
fireEvent.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.click(getFileTreeButton('scripts/example.ts'), { metaKey: true })
|
||||
|
||||
const { dataTransfer } = createDataTransfer()
|
||||
fireEvent.dragStart(exampleFile, { dataTransfer })
|
||||
|
||||
fireEvent.dragOver(targetFolder.closest('li')!, { dataTransfer })
|
||||
expect(screen.getByText(/^Move to/)).toHaveTextContent(/^Move to references$/)
|
||||
fireEvent.drop(targetFolder.closest('li')!, { dataTransfer })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'SKILL.md',
|
||||
target_path: 'references/SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'scripts/example.ts',
|
||||
target_path: 'references/example.ts',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the full file range selected with Shift-click', async () => {
|
||||
mocks.skillDetail = createSelectionSkillDetail()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => expect(getFileTreeButton('scripts/example.ts')).toBeInTheDocument())
|
||||
const exampleFile = getFileTreeItem('scripts/example.ts')
|
||||
const targetFolder = getFileTreeItem('references')
|
||||
fireEvent.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.click(getFileTreeButton('scripts/example.ts'), { shiftKey: true })
|
||||
|
||||
const { dataTransfer } = createDataTransfer()
|
||||
fireEvent.dragStart(exampleFile, { dataTransfer })
|
||||
fireEvent.dragOver(targetFolder.closest('li')!, { dataTransfer })
|
||||
fireEvent.drop(targetFolder.closest('li')!, { dataTransfer })
|
||||
|
||||
await waitFor(() => {
|
||||
for (const [path, targetPath] of [
|
||||
['SKILL.md', 'references/SKILL.md'],
|
||||
['README.md', 'references/README.md'],
|
||||
['scripts', 'references/scripts'],
|
||||
]) {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path,
|
||||
target_path: targetPath,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the previous multi-selection before dragging from the file-list blank area', async () => {
|
||||
mocks.skillDetail = createSelectionSkillDetail()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => expect(getFileTreeButton('scripts/example.ts')).toBeInTheDocument())
|
||||
fireEvent.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.click(getFileTreeButton('README.md'), primaryModifier)
|
||||
fireEvent.click(getFileTreeContextRegion())
|
||||
|
||||
const exampleFile = getFileTreeItem('scripts/example.ts')
|
||||
const targetFolder = getFileTreeItem('references')
|
||||
const { dataTransfer } = createDataTransfer()
|
||||
fireEvent.dragStart(exampleFile, { dataTransfer })
|
||||
fireEvent.dragOver(targetFolder.closest('li')!, { dataTransfer })
|
||||
fireEvent.drop(targetFolder.closest('li')!, { dataTransfer })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'scripts/example.ts',
|
||||
target_path: 'references/example.ts',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'README.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('expands a collapsed folder after a two-second drag hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
const folder = await waitFor(() => getFileTreeItem('scripts'))
|
||||
await user.dblClick(getFileTreeButton('scripts'))
|
||||
expect(document.querySelector('[title="scripts/example.ts"]')).not.toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { dataTransfer } = createDataTransfer([new File(['x'], 'x.txt')])
|
||||
fireEvent.dragOver(folder.closest('li')!, { dataTransfer })
|
||||
act(() => vi.advanceTimersByTime(1999))
|
||||
expect(document.querySelector('[title="scripts/example.ts"]')).not.toBeInTheDocument()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(getFileTreeItem('scripts/example.ts')).toBeInTheDocument()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes a file through the file tree action menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await openFileTreeActions(user, 'SKILL.md')
|
||||
await user.click(await screen.findByText('common.operation.delete'))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: 1784638487,
|
||||
operation: 'delete',
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
378
web/features/skills/__tests__/detail-page-editor.spec.tsx
Normal file
378
web/features/skills/__tests__/detail-page-editor.spec.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
createSkillDetail,
|
||||
getFileTreeButton,
|
||||
getMocks,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage editor', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('does not render the markdown editor before external file content loads', async () => {
|
||||
mocks.fetchSkillFileBlob.mockImplementation(() => new Promise<Blob>(() => undefined))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'references/guide.md',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'text/markdown',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-guide',
|
||||
size: 128,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
const { container } = renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
fireEvent.click(getFileTreeButton('references/guide.md'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.fetchSkillFileBlob).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(container.querySelector('[contenteditable="true"]')).not.toBeInTheDocument()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads external text file content into the editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.fetchSkillFileBlob.mockResolvedValue(new Blob(['# Loaded guide']))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'references/guide.md',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'text/markdown',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-guide',
|
||||
size: 128,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('references/guide.md'))
|
||||
|
||||
expect(await screen.findByText('# Loaded guide')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('textbox', {
|
||||
name: 'skill.skillManagement.detail.referenceFiles.livePlaceholder',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => {
|
||||
const editor = screen.getByRole('textbox', {
|
||||
name: 'skill.skillManagement.detail.referenceFiles.livePlaceholder',
|
||||
})
|
||||
expect(editor).toHaveAttribute('contenteditable', 'true')
|
||||
expect(editor).toHaveTextContent('# Loaded guide')
|
||||
})
|
||||
expect(mocks.fetchSkillFileBlob).toHaveBeenCalledWith({
|
||||
path: 'references/guide.md',
|
||||
skillId: 'skill-1',
|
||||
versionId: null,
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not render the code editor when external file content fails to load', async () => {
|
||||
mocks.fetchSkillFileBlob.mockRejectedValue(new Error('content unavailable'))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/action.ts',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'text/typescript',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-action',
|
||||
size: 128,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
fireEvent.click(getFileTreeButton('scripts/action.ts'))
|
||||
|
||||
expect(await screen.findByText('skill.skillManagement.detail.loadFailed')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('code-editor')).not.toBeInTheDocument()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders CSV files as a table preview', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-csv',
|
||||
path: 'references/refunds.csv',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/csv',
|
||||
content: 'Policy,Window\nStandard,7 days\nEscalated,Manual review',
|
||||
tool_file_id: null,
|
||||
size: 55,
|
||||
hash: 'hash-csv',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('references/refunds.csv'))
|
||||
|
||||
expect(screen.getByRole('columnheader', { name: 'Policy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: 'Window' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Standard')).toBeInTheDocument()
|
||||
expect(screen.getByText('7 days')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('code-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an unsupported preview for non-previewable binary files', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-bin',
|
||||
path: 'assets/archive.bin',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'application/octet-stream',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-bin',
|
||||
size: 42,
|
||||
hash: 'hash-bin',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('assets/archive.bin'))
|
||||
|
||||
expect(screen.getByText('skill.skillManagement.detail.previewUnsupported')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'skill.skillManagement.detail.fileMeta:{"size":42,"type":"application/octet-stream"}',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(mocks.fetchSkillFileBlob).not.toHaveBeenCalled()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads unsupported binary files on request', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.fetchSkillFileBlob.mockResolvedValue(new Blob(['binary']))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-bin',
|
||||
path: 'assets/archive.bin',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'application/octet-stream',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-bin',
|
||||
size: 42,
|
||||
hash: 'hash-bin',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('assets/archive.bin'))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /skill\.skillManagement\.detail\.downloadFile/ }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.fetchSkillFileBlob).toHaveBeenCalledWith({
|
||||
download: true,
|
||||
path: 'assets/archive.bin',
|
||||
skillId: 'skill-1',
|
||||
versionId: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a toast when downloading a binary file fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.fetchSkillFileBlob.mockRejectedValue(new Error('download failed'))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-bin',
|
||||
path: 'assets/archive.bin',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'application/octet-stream',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-bin',
|
||||
size: 42,
|
||||
hash: 'hash-bin',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('assets/archive.bin'))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /skill\.skillManagement\.detail\.downloadFile/ }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.detail.loadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders image tool files after loading their preview blob', async () => {
|
||||
const user = userEvent.setup()
|
||||
const imageBlob = new Blob(['image'], { type: 'image/png' })
|
||||
let previewUrlIndex = 0
|
||||
const createObjectURL = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockImplementation(() => `blob:image-preview-${++previewUrlIndex}`)
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
|
||||
mocks.fetchSkillFileBlob.mockResolvedValue(imageBlob)
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-image',
|
||||
path: 'assets/logo.png',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'image/png',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-image',
|
||||
size: 128,
|
||||
hash: 'hash-image',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { unmount } = renderSkillDetailPage({ strict: true })
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('assets/logo.png'))
|
||||
|
||||
const preview = await screen.findByAltText('assets/logo.png')
|
||||
const activePreviewUrl = createObjectURL.mock.results.at(-1)?.value
|
||||
expect(preview).toBeInTheDocument()
|
||||
expect(preview).toHaveAttribute('src', activePreviewUrl)
|
||||
expect(revokeObjectURL).not.toHaveBeenCalledWith(activePreviewUrl)
|
||||
expect(mocks.fetchSkillFileBlob).toHaveBeenCalledWith({
|
||||
path: 'assets/logo.png',
|
||||
skillId: 'skill-1',
|
||||
versionId: null,
|
||||
})
|
||||
|
||||
unmount()
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith(activePreviewUrl)
|
||||
createObjectURL.mockRestore()
|
||||
revokeObjectURL.mockRestore()
|
||||
})
|
||||
|
||||
it('shows an image preview error when the tool file blob cannot be loaded', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.fetchSkillFileBlob.mockRejectedValue(new Error('preview unavailable'))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-image',
|
||||
path: 'assets/logo.png',
|
||||
kind: 'file',
|
||||
storage: 'tool_file',
|
||||
mime_type: 'image/png',
|
||||
content: null,
|
||||
tool_file_id: 'tool-file-image',
|
||||
size: 128,
|
||||
hash: 'hash-image',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.builder.title')
|
||||
await user.click(getFileTreeButton('assets/logo.png'))
|
||||
|
||||
expect(await screen.findByText('skill.skillManagement.detail.loadFailed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps line breaks typed in the live markdown editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Guide for debugging failing GitHub Actions workflows.\nmetadata:\n display-name: Untitled skill\n---\n',
|
||||
tool_file_id: null,
|
||||
size: 148,
|
||||
hash: 'hash-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const textboxes = await screen.findAllByRole('textbox')
|
||||
const liveEditor = textboxes.find(
|
||||
(textbox): textbox is HTMLDivElement =>
|
||||
textbox instanceof HTMLDivElement && textbox.isContentEditable,
|
||||
)
|
||||
if (!liveEditor) throw new Error('live editor not found')
|
||||
|
||||
await user.click(liveEditor)
|
||||
await user.type(liveEditor, 'First line{Enter}Second line')
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.publishUpdate' }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringContaining('First line\nSecond line'),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
}, 15000)
|
||||
})
|
||||
@ -0,0 +1,440 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import {
|
||||
createSkillDetail,
|
||||
getFileTreeButton,
|
||||
getFileTreeItem,
|
||||
getMocks,
|
||||
openFileTreeActions,
|
||||
openRootCreateMenu,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage file mutations', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('creates a folder from the root file menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await openRootCreateMenu(user)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFolderMenu'))
|
||||
const folderNameInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.createFolder',
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(folderNameInput).toHaveFocus()
|
||||
expect(folderNameInput).toHaveValue('')
|
||||
await user.type(folderNameInput, 'references{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: 1784638487,
|
||||
operation: 'mkdir',
|
||||
path: 'references',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a file when a non-empty inline name loses focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await openRootCreateMenu(user)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
const fileNameInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.createFile',
|
||||
)
|
||||
|
||||
await user.type(fileNameInput, 'notes.md')
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_text',
|
||||
path: 'notes.md',
|
||||
mime_type: 'text/markdown',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite an existing file when creating a duplicate name', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...(createSkillDetail().files ?? []),
|
||||
{
|
||||
id: 'notes-file',
|
||||
path: 'notes.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# Existing notes',
|
||||
tool_file_id: null,
|
||||
size: 16,
|
||||
hash: 'notes-hash',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('notes.md')).toBeInTheDocument()
|
||||
})
|
||||
await openRootCreateMenu(user)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
const fileNameInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.createFile',
|
||||
)
|
||||
|
||||
await user.type(fileNameInput, 'notes.md')
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.fileAlreadyExists',
|
||||
)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a JSON file with a code-editor-compatible MIME type', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await openRootCreateMenu(user)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
const fileNameInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.createFile',
|
||||
)
|
||||
|
||||
await user.type(fileNameInput, 'tool.schema.json')
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
mime_type: 'application/json',
|
||||
operation: 'upsert_text',
|
||||
path: 'tool.schema.json',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the file list order returned by the service', async () => {
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
createSkillDetail().files![0]!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
{
|
||||
id: 'file-3',
|
||||
path: 'README.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# README\n',
|
||||
tool_file_id: null,
|
||||
size: 9,
|
||||
hash: 'hash-3',
|
||||
},
|
||||
{
|
||||
id: 'file-4',
|
||||
path: 'notes.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '',
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'hash-4',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('notes.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const expectedOrder = ['SKILL.md', 'scripts', 'README.md', 'notes.md'].map(getFileTreeItem)
|
||||
for (const [index, item] of expectedOrder.entries()) {
|
||||
const nextItem = expectedOrder[index + 1]
|
||||
if (nextItem)
|
||||
expect(item.compareDocumentPosition(nextItem)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a file inline inside a folder', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('scripts')).toBeInTheDocument()
|
||||
})
|
||||
await openFileTreeActions(user, 'scripts')
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
const fileNameInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.createFile',
|
||||
)
|
||||
|
||||
expect(fileNameInput.closest('ul')).toContainElement(getFileTreeItem('scripts/example.ts'))
|
||||
await user.type(fileNameInput, 'helper.ts{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_text',
|
||||
path: 'scripts/helper.ts',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a folder inline inside a folder', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('scripts')).toBeInTheDocument()
|
||||
})
|
||||
await openFileTreeActions(user, 'scripts')
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFolderMenu'))
|
||||
const folderNameInput = await screen.findByPlaceholderText(
|
||||
'skill.skillManagement.detail.createFolder',
|
||||
)
|
||||
|
||||
expect(folderNameInput.closest('ul')).toContainElement(getFileTreeItem('scripts/example.ts'))
|
||||
await user.type(folderNameInput, 'helpers{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'mkdir',
|
||||
path: 'scripts/helpers',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses and expands nested folders from the file tree', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
const folder = await waitFor(() => getFileTreeButton('scripts'))
|
||||
expect(getFileTreeButton('scripts/example.ts')).toBeInTheDocument()
|
||||
|
||||
await user.dblClick(folder)
|
||||
expect(document.querySelector('[title="scripts/example.ts"]')).not.toBeInTheDocument()
|
||||
|
||||
await user.dblClick(folder)
|
||||
expect(getFileTreeButton('scripts/example.ts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses only the native path tooltip for a file tree item', async () => {
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('scripts/example.ts')).toBeInTheDocument()
|
||||
})
|
||||
const fileButton = getFileTreeButton('scripts/example.ts')
|
||||
|
||||
expect(fileButton).toHaveAttribute('title', 'scripts/example.ts')
|
||||
expect(screen.queryByText('scripts/example.ts')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renames a file inline and selects its name without the extension', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'scripts/example.jsonl',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'application/jsonl',
|
||||
content: '',
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('scripts/example.jsonl')).toBeInTheDocument()
|
||||
})
|
||||
await openFileTreeActions(user, 'scripts/example.jsonl')
|
||||
await user.click(await screen.findByText('common.operation.rename...'))
|
||||
const renameInput = await screen.findByDisplayValue('example.jsonl')
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(renameInput).toHaveFocus()
|
||||
expect(renameInput).toHaveProperty('selectionStart', 0)
|
||||
expect(renameInput).toHaveProperty('selectionEnd', 7)
|
||||
|
||||
await user.clear(renameInput)
|
||||
await user.type(renameInput, 'renamed.jsonl{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'rename',
|
||||
path: 'scripts/example.jsonl',
|
||||
target_path: 'scripts/renamed.jsonl',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels inline rename without saving when the name is unchanged', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await openFileTreeActions(user, 'SKILL.md')
|
||||
await user.click(await screen.findByText('common.operation.rename...'))
|
||||
const renameInput = await screen.findByDisplayValue('SKILL.md')
|
||||
|
||||
fireEvent.keyDown(renameInput, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameInput).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a toast when creating a file from the file tree fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.saveDraftFileMutationFn.mockRejectedValueOnce(new Error('backend exploded'))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await openRootCreateMenu(user)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('skill.skillManagement.detail.createFile'),
|
||||
'broken.md{Enter}',
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('backend exploded')
|
||||
})
|
||||
})
|
||||
})
|
||||
861
web/features/skills/__tests__/detail-page-metadata.spec.tsx
Normal file
861
web/features/skills/__tests__/detail-page-metadata.spec.tsx
Normal file
@ -0,0 +1,861 @@
|
||||
import type { SkillDetailResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { act, fireEvent, 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 {
|
||||
createSkillDetail,
|
||||
createSkillVersion,
|
||||
getFileTreeButton,
|
||||
getMocks,
|
||||
getSourceEditor,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage metadata', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('opens the inline tag selector with workspace tag options', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const addTagButton = await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
})
|
||||
await user.click(addTagButton)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'common.tag.selectorPlaceholder',
|
||||
}),
|
||||
).toHaveFocus()
|
||||
expect(screen.getByRole('option', { name: 'Search' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'Productivity' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.tag.manageTags' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('heading', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves selected workspace tags when the selector closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: 1784638487,
|
||||
tags: ['Search'],
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('removes a selected tag when it is unchecked and the selector closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
tags: ['Search', 'Productivity'],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
tags: ['Productivity'],
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders an unmatched search as a create action instead of a tag checkbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.type(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'common.tag.selectorPlaceholder',
|
||||
}),
|
||||
'BrandNew',
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('option', {
|
||||
name: "common.tag.create 'BrandNew'",
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'BrandNew' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates and binds an unmatched tag when the create action is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.type(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'common.tag.selectorPlaceholder',
|
||||
}),
|
||||
'BrandNew',
|
||||
)
|
||||
await user.click(
|
||||
await screen.findByRole('option', {
|
||||
name: "common.tag.create 'BrandNew'",
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
tags: ['BrandNew'],
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an added tag only after the metadata request finishes', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveMutation: ((detail: SkillDetailResponse) => void) | undefined
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
mocks.skillDetailQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-detail'],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
mocks.skillMetadataMutationFn.mockImplementation(
|
||||
() =>
|
||||
new Promise<SkillDetailResponse>((resolve) => {
|
||||
resolveMutation = resolve
|
||||
}),
|
||||
)
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'Search' }))
|
||||
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
|
||||
|
||||
expect(screen.queryByText('Search')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
const nextDetail = createSkillDetail({
|
||||
tags: ['Search'],
|
||||
updated_at: 1784638491,
|
||||
})
|
||||
mocks.skillDetail = nextDetail
|
||||
resolveMutation?.(nextDetail)
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens tag management from the selector', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('combobox', {
|
||||
name: 'skill.skillManagement.detail.addTag',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.tag.manageTags' }))
|
||||
|
||||
expect(await screen.findByRole('dialog')).toHaveTextContent('common.tag.manageTags')
|
||||
})
|
||||
|
||||
it('removes an existing tag from its badge action', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
tags: ['Search', 'Productivity', 'Utilities', 'Pre-sales'],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(await screen.findByText('Search')).toBeInTheDocument()
|
||||
expect(screen.getByText('Productivity')).toBeInTheDocument()
|
||||
expect(screen.getByText('Utilities')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pre-sales')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.removeTag:{"tag":"Search"}',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
tags: ['Productivity', 'Utilities', 'Pre-sales'],
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('hides a removed tag only after the metadata request finishes', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveMutation: ((detail: SkillDetailResponse) => void) | undefined
|
||||
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
|
||||
mocks.skillDetailQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-detail'],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
tags: ['Search', 'Productivity'],
|
||||
})
|
||||
mocks.skillMetadataMutationFn.mockImplementation(
|
||||
() =>
|
||||
new Promise<SkillDetailResponse>((resolve) => {
|
||||
resolveMutation = resolve
|
||||
}),
|
||||
)
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.removeTag:{"tag":"Search"}',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByText('Search')).toBeInTheDocument()
|
||||
expect(screen.getByText('Productivity')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
const nextDetail = createSkillDetail({
|
||||
tags: ['Productivity'],
|
||||
updated_at: 1784638491,
|
||||
})
|
||||
mocks.skillDetail = nextDetail
|
||||
resolveMutation?.(nextDetail)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose display-name editing in the SKILL.md metadata editor before publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(await screen.findByText('name')).toBeInTheDocument()
|
||||
expect(screen.getByText('description')).toBeInTheDocument()
|
||||
expect(screen.queryByText('display-name')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.publishUpdate' }),
|
||||
)
|
||||
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('renames the skill from the sidebar title without changing SKILL.md content', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.rename' }))
|
||||
const renameInput = screen.getByRole('textbox', { name: 'common.operation.rename' })
|
||||
expect(renameInput).toHaveFocus()
|
||||
expect(renameInput).toHaveValue('Untitled skill')
|
||||
expect(renameInput).toHaveProperty('selectionStart', 0)
|
||||
expect(renameInput).toHaveProperty('selectionEnd', 'Untitled skill'.length)
|
||||
await user.clear(renameInput)
|
||||
await user.type(renameInput, 'Renamed skill{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
display_name: 'Renamed skill',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.renameSkillSuccess',
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
|
||||
'Renamed skill',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('starts inline rename from the sidebar More menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const moreButton = await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
})
|
||||
await user.click(moreButton)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.rename' }))
|
||||
const renameInput = screen.getByRole('textbox', { name: 'common.operation.rename' })
|
||||
expect(renameInput).toHaveFocus()
|
||||
expect(renameInput).toHaveValue('Untitled skill')
|
||||
})
|
||||
|
||||
it('duplicates and exports the current skill from the sidebar More menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const archive = new Blob(['archive'], { type: 'application/zip' })
|
||||
mocks.duplicateSkillMutationFn.mockResolvedValue({})
|
||||
mocks.fetchSkillArchiveBlob.mockResolvedValue(archive)
|
||||
renderSkillDetailPage()
|
||||
|
||||
const moreButton = await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
})
|
||||
await user.click(moreButton)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith(
|
||||
{ params: { skill_id: 'skill-1' } },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
|
||||
|
||||
await user.click(moreButton)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.export' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.fetchSkillArchiveBlob).toHaveBeenCalledWith('skill-1')
|
||||
expect(mocks.downloadBlob).toHaveBeenCalledWith({
|
||||
data: archive,
|
||||
fileName: 'github-actions-failure-debugging.zip',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('requires the display name before deleting a referenced skill from the sidebar', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
mocks.deleteSkillMutationFn.mockResolvedValue({})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
}),
|
||||
)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.delete' }))
|
||||
|
||||
expect(
|
||||
screen.getByText('skill.skillManagement.deleteDialog.title:{"name":"Untitled skill"}'),
|
||||
).toBeInTheDocument()
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
const confirmationInput = within(dialog).getByPlaceholderText(
|
||||
'skill.skillManagement.deleteDialog.confirmInputPlaceholder',
|
||||
)
|
||||
const confirmButton = within(dialog).getByRole('button', {
|
||||
name: 'common.operation.confirm',
|
||||
})
|
||||
expect(confirmButton).toBeDisabled()
|
||||
|
||||
await user.type(confirmationInput, 'Untitled skill')
|
||||
expect(confirmButton).toBeEnabled()
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.deleteSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
body: { confirmation_name: 'Untitled skill' },
|
||||
params: { skill_id: 'skill-1' },
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith('/skills')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps sidebar deletion disabled while cached references refresh', async () => {
|
||||
const user = userEvent.setup()
|
||||
let referenceRequestCount = 0
|
||||
let shouldHangReferenceRequest = false
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references-pending', options],
|
||||
queryFn: () => {
|
||||
referenceRequestCount += 1
|
||||
if (!shouldHangReferenceRequest) return Promise.resolve({ data: [] })
|
||||
|
||||
return new Promise(() => {})
|
||||
},
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
const moreButton = await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
})
|
||||
await user.click(moreButton)
|
||||
await user.click(screen.getByRole('menuitem', { name: 'common.operation.delete' }))
|
||||
let dialog = screen.getByRole('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(screen.getByRole('menuitem', { name: 'common.operation.delete' }))
|
||||
dialog = screen.getByRole('alertdialog')
|
||||
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: 'common.operation.delete',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
await waitFor(() => {
|
||||
expect(referenceRequestCount).toBeGreaterThan(initialRequestCount)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose mutable More actions while viewing a published version', async () => {
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({ data: [createSkillVersion()] }),
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
await userEvent.setup().click(await screen.findByText('Initial version'))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels an empty sidebar rename when the field loses focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.rename' }))
|
||||
const renameInput = screen.getByRole('textbox', { name: 'common.operation.rename' })
|
||||
await user.clear(renameInput)
|
||||
await user.tab()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
|
||||
'Untitled skill',
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks changes as published and enables publish update after new edits', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { queryClient } = renderSkillDetailPage()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
const publishButton = await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
})
|
||||
expect(publishButton).toBeEnabled()
|
||||
|
||||
await user.click(publishButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['skills', { type: 'infinite' }],
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(document.body).toHaveTextContent('skill.skillManagement.detail.upToDate')
|
||||
})
|
||||
expect(publishButton).toBeDisabled()
|
||||
expect(publishButton).toHaveAccessibleName('skill.skillManagement.detail.published')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nUpdated published instructions')
|
||||
|
||||
expect(publishButton).toBeEnabled()
|
||||
expect(publishButton).toHaveAccessibleName('skill.skillManagement.detail.publishUpdate')
|
||||
expect(document.body).toHaveTextContent('skill.skillManagement.detail.unpublishedChanges')
|
||||
})
|
||||
|
||||
it('keeps the skill timestamp for metadata updates after publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const skillUpdatedAt = 1784638490
|
||||
const versionCreatedAt = 1784638491
|
||||
mocks.skillDetail = createSkillDetail({ updated_at: skillUpdatedAt })
|
||||
mocks.publishSkillMutationFn.mockImplementationOnce(async () => {
|
||||
const version = {
|
||||
id: 'version-2',
|
||||
version_number: 2,
|
||||
version_name: '',
|
||||
publish_note: '',
|
||||
hash_code: 'hash-code',
|
||||
archive_size: 180,
|
||||
published_by: 'user-1',
|
||||
published_by_name: 'Fate',
|
||||
created_at: versionCreatedAt,
|
||||
is_latest: true,
|
||||
}
|
||||
mocks.skillDetail = mocks.skillDetail
|
||||
? {
|
||||
...mocks.skillDetail,
|
||||
latest_published_at: version.created_at,
|
||||
latest_published_version_id: version.id,
|
||||
latest_published_version_number: version.version_number,
|
||||
}
|
||||
: mocks.skillDetail
|
||||
return version
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.rename' }))
|
||||
const renameInput = screen.getByRole('textbox', { name: 'common.operation.rename' })
|
||||
await user.clear(renameInput)
|
||||
await user.type(renameInput, 'Renamed after publish{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
display_name: 'Renamed after publish',
|
||||
expected_updated_at: skillUpdatedAt,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
expected_updated_at: versionCreatedAt,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('adds custom metadata from the value field Enter key and saves it on publish', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.addMetadata',
|
||||
}),
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.metadataKey'),
|
||||
'owner',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.metadataValue'),
|
||||
'support{Enter}',
|
||||
)
|
||||
expect(await screen.findByDisplayValue('owner')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('support')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.publishUpdate' }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringContaining(' owner: support'),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('commits custom metadata on blur so another entry can be added and published', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
updated_at: 1784638400,
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.published',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.addMetadata',
|
||||
}),
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.metadataKey'),
|
||||
'owner',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.metadataValue'),
|
||||
'support',
|
||||
)
|
||||
await user.tab()
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'owner value' })).toHaveValue('support')
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.addMetadata',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
).toBeEnabled()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.addMetadata',
|
||||
}),
|
||||
)
|
||||
await user.type(screen.getByPlaceholderText('skill.skillManagement.detail.metadataKey'), 'team')
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.metadataValue'),
|
||||
'success',
|
||||
)
|
||||
await user.tab()
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'team value' })).toHaveValue('success')
|
||||
expect(screen.getByRole('textbox', { name: 'owner value' })).toHaveValue('support')
|
||||
})
|
||||
|
||||
it('cancels custom metadata creation from both metadata fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.addMetadata',
|
||||
}),
|
||||
)
|
||||
const keyInput = screen.getByPlaceholderText('skill.skillManagement.detail.metadataKey')
|
||||
await user.type(keyInput, 'owner{Escape}')
|
||||
expect(
|
||||
screen.queryByPlaceholderText('skill.skillManagement.detail.metadataKey'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.addMetadata',
|
||||
}),
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('skill.skillManagement.detail.metadataValue'),
|
||||
'support{Escape}',
|
||||
)
|
||||
expect(
|
||||
screen.queryByPlaceholderText('skill.skillManagement.detail.metadataValue'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('owner')).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('support')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves edits made directly in the manifest name and description fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('github-actions-failure-debugging')
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'customer-issue-triage')
|
||||
const descriptionInput = screen.getByDisplayValue(
|
||||
'Guide for debugging failing GitHub Actions workflows.',
|
||||
)
|
||||
await user.clear(descriptionInput)
|
||||
await user.type(descriptionInput, 'Classify support issues by severity.')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringMatching(
|
||||
/name: customer-issue-triage[\s\S]*description: Classify support issues by severity\./,
|
||||
),
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
})
|
||||
|
||||
it('updates and removes existing custom metadata from the manifest editor', async () => {
|
||||
const content =
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Guide for debugging failing GitHub Actions workflows.\nmetadata:\n display-name: Untitled skill\n owner: support\n---\n# GitHub Actions Failure Debugging\n'
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
{
|
||||
...createSkillDetail().files![0]!,
|
||||
content,
|
||||
size: content.length,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const ownerValue = await screen.findByRole('textbox', { name: 'owner value' })
|
||||
fireEvent.change(ownerValue, { target: { value: 'success' } })
|
||||
fireEvent.blur(ownerValue)
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringContaining(' owner: success'),
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove owner' }))
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.not.stringContaining(' owner:'),
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render Skill metadata controls for non-SKILL markdown files', async () => {
|
||||
const user = userEvent.setup()
|
||||
const defaultFiles = createSkillDetail().files!
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'references/refund-policy.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: refund-policy\ndescription: Refund policy.\nmetadata:\n display-name: Refund Policy\n---\n# 退款政策\n',
|
||||
tool_file_id: null,
|
||||
size: 109,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
...defaultFiles,
|
||||
],
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('references'))
|
||||
fireEvent.click(getFileTreeButton('references/refund-policy.md'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('textbox')
|
||||
.map((textbox) => ('value' in textbox ? String(textbox.value) : textbox.textContent))
|
||||
.join('\n'),
|
||||
).toContain('# 退款政策')
|
||||
})
|
||||
expect(screen.queryByDisplayValue('refund-policy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('Refund policy.')).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('Refund Policy')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'skill.skillManagement.detail.addMetadata' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
421
web/features/skills/__tests__/detail-page-navigation.spec.tsx
Normal file
421
web/features/skills/__tests__/detail-page-navigation.spec.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import {
|
||||
createFileTabSkillDetail,
|
||||
createReferencePickerSkillDetail,
|
||||
createSkillDetail,
|
||||
getFileTabButton,
|
||||
getFileTreeButton,
|
||||
getLiveMarkdownEditor,
|
||||
getMocks,
|
||||
getReferencePicker,
|
||||
getReferencePickerButton,
|
||||
getSourceEditor,
|
||||
placeCaretAtEnd,
|
||||
preserveDraftFilesOnSave,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
function TestGotoAnythingDialog() {
|
||||
return (
|
||||
<Dialog handle={gotoAnythingDialogHandle}>
|
||||
<DialogPortal>
|
||||
<DialogPopup>
|
||||
<DialogTitle>Goto Anything</DialogTitle>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkillDetailPage navigation', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('opens Go to Anything from the sidebar search action', async () => {
|
||||
renderSkillDetailPage()
|
||||
render(<TestGotoAnythingDialog />)
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Goto Anything' })).not.toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'app.gotoAnything.searchTitle' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands the file tree sidebar', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.collapseSidebar',
|
||||
}),
|
||||
)
|
||||
expect(screen.queryByTestId('skill-detail-sidebar-header')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.expandSidebar',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the sidebar while the collapsed rail is hovered', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.collapseSidebar',
|
||||
}),
|
||||
)
|
||||
expect(screen.queryByTestId('skill-detail-sidebar-header')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId('skill-detail-sidebar-shell'))
|
||||
|
||||
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.collapseSidebar',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resizes the file tree sidebar within its accessible range', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
const resizeHandle = await screen.findByRole('separator', {
|
||||
name: 'skill.skillManagement.detail.resizeSidebar',
|
||||
})
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuemin', '240')
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuemax', '420')
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
|
||||
|
||||
fireEvent.pointerDown(resizeHandle, { button: 0, clientX: 244 })
|
||||
fireEvent.pointerMove(document, { clientX: 600 })
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '420')
|
||||
|
||||
fireEvent.pointerMove(document, { clientX: 0 })
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
|
||||
fireEvent.pointerUp(document)
|
||||
|
||||
fireEvent.keyDown(resizeHandle, { key: 'ArrowRight' })
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '248')
|
||||
fireEvent.keyDown(resizeHandle, { key: 'End' })
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '420')
|
||||
fireEvent.keyDown(resizeHandle, { key: 'Home' })
|
||||
expect(resizeHandle).toHaveAttribute('aria-valuenow', '240')
|
||||
})
|
||||
|
||||
it('navigates, filters, closes, and inserts from the source reference picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createReferencePickerSkillDetail()
|
||||
preserveDraftFilesOnSave()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
const sourceEditor = getSourceEditor()
|
||||
sourceEditor.focus()
|
||||
sourceEditor.setSelectionRange(sourceEditor.value.length, sourceEditor.value.length)
|
||||
|
||||
await user.keyboard('/')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(getReferencePickerButton('docs'))
|
||||
expect(within(getReferencePicker()).getByText('docs')).toBeInTheDocument()
|
||||
expect(getReferencePickerButton('guide.md')).toBeInTheDocument()
|
||||
await user.click(getReferencePickerButton('..'))
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
sourceEditor.focus()
|
||||
sourceEditor.setSelectionRange(sourceEditor.value.length, sourceEditor.value.length)
|
||||
await user.keyboard('read')
|
||||
await waitFor(() => {
|
||||
expect(within(getReferencePicker()).getByText('read')).toBeInTheDocument()
|
||||
})
|
||||
expect(getReferencePickerButton('README.md')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
sourceEditor.focus()
|
||||
sourceEditor.setSelectionRange(sourceEditor.value.length, sourceEditor.value.length)
|
||||
await user.keyboard('/')
|
||||
sourceEditor.focus()
|
||||
sourceEditor.setSelectionRange(sourceEditor.value.length, sourceEditor.value.length)
|
||||
await user.keyboard('read')
|
||||
await user.click(getReferencePickerButton('README.md'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sourceEditor.value).toContain('[README.md](<README.md>)')
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates the source reference picker with arrow keys', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createReferencePickerSkillDetail()
|
||||
preserveDraftFilesOnSave()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
const sourceEditor = getSourceEditor()
|
||||
sourceEditor.focus()
|
||||
sourceEditor.setSelectionRange(sourceEditor.value.length, sourceEditor.value.length)
|
||||
|
||||
await user.keyboard('/')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(getReferencePicker()).getByText('docs')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(getReferencePicker()).getByText('docs')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowRight}{ArrowDown}{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sourceEditor.value).toContain('[reference.md](<docs/reference.md>)')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles reference picker Enter and ArrowUp keyboard edge cases', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createReferencePickerSkillDetail()
|
||||
preserveDraftFilesOnSave()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
const sourceEditor = getSourceEditor()
|
||||
sourceEditor.focus()
|
||||
sourceEditor.setSelectionRange(sourceEditor.value.length, sourceEditor.value.length)
|
||||
|
||||
await user.keyboard('/')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowDown}{ArrowUp}{Enter}')
|
||||
expect(within(getReferencePicker()).getByText('docs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inserts a reference file from live markdown slash picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createReferencePickerSkillDetail()
|
||||
preserveDraftFilesOnSave()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownLiveMode',
|
||||
})
|
||||
const livePreview = screen
|
||||
.getAllByRole('textbox')
|
||||
.find((editor): editor is HTMLDivElement => editor instanceof HTMLDivElement)
|
||||
if (!livePreview) throw new Error('live markdown preview not found')
|
||||
|
||||
await user.click(livePreview)
|
||||
const liveEditor = await waitFor(() => getLiveMarkdownEditor())
|
||||
liveEditor.focus()
|
||||
placeCaretAtEnd(liveEditor)
|
||||
await user.keyboard('/')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowRight}{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(liveEditor.textContent).toContain('guide.md')
|
||||
})
|
||||
})
|
||||
|
||||
it('inserts a reference from the live picker by clicking through a directory', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createReferencePickerSkillDetail()
|
||||
preserveDraftFilesOnSave()
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownLiveMode',
|
||||
})
|
||||
const livePreview = screen
|
||||
.getAllByRole('textbox')
|
||||
.find((editor): editor is HTMLDivElement => editor instanceof HTMLDivElement)
|
||||
if (!livePreview) throw new Error('live markdown preview not found')
|
||||
|
||||
await user.click(livePreview)
|
||||
const liveEditor = await waitFor(() => getLiveMarkdownEditor())
|
||||
liveEditor.focus()
|
||||
placeCaretAtEnd(liveEditor)
|
||||
await user.keyboard('/')
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(getReferencePickerButton('docs'))
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('skill.skillManagement.detail.referenceFiles.title'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the full reference path on hover and opens the referenced file in an editor tab', async () => {
|
||||
const manifestContent =
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Guide for debugging failing GitHub Actions workflows.\nmetadata:\n display-name: Untitled skill\n---\n# Guide\n\nRead [guide.md](<docs/guide.md>) before continuing.\n'
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
{
|
||||
...createSkillDetail().files![0]!,
|
||||
content: manifestContent,
|
||||
size: manifestContent.length,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
path: 'docs/guide.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# Guide',
|
||||
tool_file_id: null,
|
||||
size: 7,
|
||||
hash: 'hash-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { container } = renderSkillDetailPage()
|
||||
const reference = await waitFor(() => {
|
||||
const element = container.querySelector<HTMLElement>('[data-reference-path="docs/guide.md"]')
|
||||
expect(element).toBeInTheDocument()
|
||||
return element!
|
||||
})
|
||||
|
||||
expect(reference).toHaveAttribute('title', 'docs/guide.md')
|
||||
|
||||
fireEvent.click(reference)
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.closeFileTab:{"name":"docs/guide.md"}',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps SKILL.md open and replaces the previous temporary tab on sidebar single click', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createFileTabSkillDetail()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownLiveMode',
|
||||
})
|
||||
expect(getFileTabButton('SKILL.md')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'skill.skillManagement.detail.closeFileTab:{"name":"SKILL.md"}',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(getFileTreeButton('README.md'))
|
||||
expect(getFileTabButton('README.md')).toBeInTheDocument()
|
||||
|
||||
await user.click(getFileTreeButton('prompt.md'))
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'skill.skillManagement.detail.closeFileTab:{"name":"README.md"}',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
expect(getFileTabButton('prompt.md')).toBeInTheDocument()
|
||||
expect(getFileTabButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pins a file tab on sidebar double click', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createFileTabSkillDetail()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownLiveMode',
|
||||
})
|
||||
await user.dblClick(getFileTreeButton('README.md'))
|
||||
|
||||
await user.click(getFileTreeButton('prompt.md'))
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.closeFileTab:{"name":"README.md"}',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(getFileTabButton('prompt.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('promotes a temporary tab to a pinned tab when its file is edited', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createFileTabSkillDetail()
|
||||
preserveDraftFilesOnSave()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownLiveMode',
|
||||
})
|
||||
await user.click(getFileTreeButton('notes.txt'))
|
||||
|
||||
const notesEditor = screen
|
||||
.getAllByRole('textbox')
|
||||
.find(
|
||||
(textbox): textbox is HTMLTextAreaElement =>
|
||||
textbox instanceof HTMLTextAreaElement && textbox.value === 'Notes',
|
||||
)
|
||||
expect(notesEditor).toBeDefined()
|
||||
await user.clear(notesEditor!)
|
||||
await user.type(notesEditor!, 'Updated notes')
|
||||
await user.click(getFileTreeButton('README.md'))
|
||||
expect(getFileTabButton('notes.txt')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
626
web/features/skills/__tests__/detail-page-publishing.spec.tsx
Normal file
626
web/features/skills/__tests__/detail-page-publishing.spec.tsx
Normal file
@ -0,0 +1,626 @@
|
||||
import type { SkillReferenceResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { 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 {
|
||||
createAgentReference,
|
||||
createSkillDetail,
|
||||
createSkillVersion,
|
||||
getMocks,
|
||||
openVersionRowActions,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage publishing', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('shows only the detailed backend error when publishing fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.publishSkillMutationFn.mockRejectedValueOnce(
|
||||
new Error('SKILL.md frontmatter name is required'),
|
||||
)
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(mocks.publishSkillMutationOptions).toHaveBeenCalledWith({
|
||||
context: { silent: true },
|
||||
})
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('SKILL.md frontmatter name is required')
|
||||
})
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows a publish confirmation for referenced skills before publishing updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [createAgentReference()],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText('skill.skillManagement.detail.publishReferencesTitle'),
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Support Agent')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Support Agent/ })).toHaveAttribute('target', '_blank')
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
const publishDialog = screen.getByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
})
|
||||
expect(screen.getByTestId('skill-publish-bar')).not.toBeVisible()
|
||||
await user.click(
|
||||
within(publishDialog).getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes a fresh empty references cache before publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const referencesQueryKey = [
|
||||
'skill-references',
|
||||
{
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
},
|
||||
},
|
||||
]
|
||||
let remoteReferences: SkillReferenceResponse[] = []
|
||||
const referencesQueryFn = vi.fn(async () => ({ data: remoteReferences }))
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', (options as { input: unknown }).input],
|
||||
queryFn: referencesQueryFn,
|
||||
}))
|
||||
queryClient.setQueryData(referencesQueryKey, { data: [] })
|
||||
|
||||
renderSkillDetailPage({ queryClient })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(referencesQueryFn).toHaveBeenCalled()
|
||||
expect(queryClient.getQueryState(referencesQueryKey)?.fetchStatus).toBe('idle')
|
||||
})
|
||||
remoteReferences = [
|
||||
createAgentReference({
|
||||
display_name: 'Fresh Cache Agent',
|
||||
name: 'fresh-cache-agent',
|
||||
}),
|
||||
]
|
||||
referencesQueryFn.mockClear()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(referencesQueryFn).toHaveBeenCalled()
|
||||
expect(
|
||||
await screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('skill.skillManagement.detail.publishReferencesDescription_one:{"count":1}'),
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Fresh Cache Agent')).toBeInTheDocument()
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not publish when refreshing references fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
let refreshShouldFail = false
|
||||
const referencesQueryFn = vi.fn(async () => {
|
||||
if (refreshShouldFail) throw new Error('references unavailable')
|
||||
return { data: [] }
|
||||
})
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', (options as { input: unknown }).input],
|
||||
queryFn: referencesQueryFn,
|
||||
}))
|
||||
|
||||
renderSkillDetailPage({ queryClient })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(referencesQueryFn).toHaveBeenCalled()
|
||||
expect(queryClient.isFetching()).toBe(0)
|
||||
})
|
||||
refreshShouldFail = true
|
||||
referencesQueryFn.mockClear()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(referencesQueryFn).toHaveBeenCalledOnce())
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.queryByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels publishing from the referenced skill confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [createAgentReference()],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
const publishDialog = await screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
})
|
||||
await user.click(within(publishDialog).getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the sidebar references panel from the file tree footer', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [createAgentReference({ display_name: 'Sidebar Agent', name: 'sidebar-agent' })],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.referencedBy_one:{"count":1}',
|
||||
}),
|
||||
)
|
||||
|
||||
const referencesPopover = await screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.referencedBy_one:{"count":1}',
|
||||
})
|
||||
const sidebarReferenceLink = within(referencesPopover).getByRole('link', {
|
||||
name: /Sidebar Agent/,
|
||||
})
|
||||
expect(sidebarReferenceLink).toBeInTheDocument()
|
||||
expect(mocks.skillReferencesQueryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a non-interactive reference count when no agent uses the skill', async () => {
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
const referenceCount = await screen.findByText(
|
||||
'skill.skillManagement.detail.referencedBy_other:{"count":0}',
|
||||
)
|
||||
expect(referenceCount).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'skill.skillManagement.detail.referencedBy_other:{"count":0}',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the references query count when the cached sidebar reference count is stale', async () => {
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [createAgentReference({ display_name: 'Sidebar Agent', name: 'sidebar-agent' })],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'skill.skillManagement.detail.referencedBy_one:{"count":1}' },
|
||||
{ timeout: 5000 },
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the sidebar references panel when clicking outside it', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [createAgentReference({ display_name: 'Sidebar Agent', name: 'sidebar-agent' })],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.referencedBy_one:{"count":1}',
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByText('Sidebar Agent')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'app.gotoAnything.searchTitle',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Sidebar Agent')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals the remaining publish references on request', async () => {
|
||||
const user = userEvent.setup()
|
||||
const references = Array.from({ length: 11 }, (_, index) =>
|
||||
createAgentReference({
|
||||
agent_id: `agent-${index + 1}`,
|
||||
app_id: `app-${index + 1}`,
|
||||
display_name: `Reference ${index + 1}`,
|
||||
name: `reference-${index + 1}`,
|
||||
}),
|
||||
)
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: references.length })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({ data: references }),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
const referenceList = await screen.findByTestId('skill-publish-reference-list')
|
||||
expect(within(referenceList).getAllByRole('link')).toHaveLength(5)
|
||||
expect(within(referenceList).queryByText('Reference 6')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(referenceList).getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.showMoreReferences:{"count":6}',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(within(referenceList).getAllByRole('link')).toHaveLength(11)
|
||||
})
|
||||
|
||||
it('renders selected version files in read-only mode and restores that version', async () => {
|
||||
const user = userEvent.setup()
|
||||
const version = createSkillVersion({
|
||||
id: 'version-1',
|
||||
version_name: 'Rollback target',
|
||||
})
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({
|
||||
data: [version],
|
||||
}),
|
||||
}))
|
||||
mocks.skillVersionDetailQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-version-detail', options],
|
||||
queryFn: async () => ({
|
||||
...version,
|
||||
files: [
|
||||
{
|
||||
id: 'version-file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Old description.\nmetadata:\n display-name: Rollback skill\n---\n# Rollback instructions\n',
|
||||
tool_file_id: null,
|
||||
size: 140,
|
||||
hash: 'version-hash-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
const { queryClient } = renderSkillDetailPage()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: /Rollback target/ }))
|
||||
|
||||
expect(await screen.findByText(/Rollback instructions/)).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.restoreVersion' }),
|
||||
)
|
||||
await user.click(
|
||||
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.restoreVersion',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.restoreSkillMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
version_id: 'version-1',
|
||||
version_name: 'Rollback target',
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['skills', { type: 'infinite' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('displays an unnamed version by its per-skill sequence number', async () => {
|
||||
const user = userEvent.setup()
|
||||
const version = createSkillVersion({
|
||||
id: 'version-2',
|
||||
version_number: 2,
|
||||
version_name: '',
|
||||
publish_note: 'Release note only',
|
||||
})
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({
|
||||
data: [version],
|
||||
}),
|
||||
}))
|
||||
mocks.skillVersionDetailQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-version-detail', options],
|
||||
queryFn: async () => ({
|
||||
...version,
|
||||
files: [
|
||||
{
|
||||
id: 'version-file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Old description.\n---\n# Rollback instructions\n',
|
||||
tool_file_id: null,
|
||||
size: 120,
|
||||
hash: 'version-hash-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: /#2/ }))
|
||||
|
||||
expect(await screen.findAllByText('#2')).toHaveLength(2)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.restoreVersion' }),
|
||||
)
|
||||
await user.click(
|
||||
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.restoreVersion',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.restoreSkillMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
version_id: 'version-2',
|
||||
version_name: '',
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('filters version history and returns to the current draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const namedVersion = createSkillVersion({
|
||||
id: 'version-1',
|
||||
is_latest: true,
|
||||
version_name: 'Named version',
|
||||
})
|
||||
const unnamedVersion = createSkillVersion({
|
||||
id: 'version-2',
|
||||
version_number: 2,
|
||||
version_name: '',
|
||||
})
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({
|
||||
data: [namedVersion, unnamedVersion],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.versions')
|
||||
expect(screen.getAllByRole('button', { current: true })).toHaveLength(1)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /workflow\.versionHistory\.filter\.all/,
|
||||
}),
|
||||
)
|
||||
await user.click(
|
||||
await screen.findByText('workflow.versionHistory.filter.onlyShowNamedVersions'),
|
||||
)
|
||||
|
||||
expect(screen.queryByText('#2')).not.toBeInTheDocument()
|
||||
|
||||
const currentDraft = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.currentDraft',
|
||||
})
|
||||
await user.click(currentDraft)
|
||||
expect(currentDraft).toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
|
||||
it('renames a version title and publish note from the version menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const version = createSkillVersion({
|
||||
id: 'version-1',
|
||||
publish_note: 'Initial note',
|
||||
version_name: 'Initial version',
|
||||
})
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({
|
||||
data: [version],
|
||||
}),
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
await openVersionRowActions(user, 'Initial version')
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.nameThisVersion'))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const [titleInput, noteInput] = within(dialog).getAllByRole('textbox')
|
||||
if (!titleInput || !noteInput) throw new Error('version info inputs not found')
|
||||
|
||||
await user.clear(titleInput)
|
||||
await user.type(titleInput, 'Named version')
|
||||
await user.clear(noteInput)
|
||||
await user.type(noteInput, 'Release note')
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: 'skill.skillManagement.detail.publish' }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.versionPatchMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
publish_note: 'Release note',
|
||||
version_name: 'Named version',
|
||||
},
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
version_id: 'version-1',
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a non-latest version from the version menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const version = createSkillVersion({
|
||||
id: 'version-1',
|
||||
is_latest: false,
|
||||
version_name: 'Old version',
|
||||
})
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({
|
||||
data: [version],
|
||||
}),
|
||||
}))
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
await openVersionRowActions(user, 'Old version')
|
||||
await user.click(await screen.findByText('common.operation.delete'))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.versionDeleteMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
version_id: 'version-1',
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
360
web/features/skills/__tests__/detail-page-uploads.spec.tsx
Normal file
360
web/features/skills/__tests__/detail-page-uploads.spec.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
confirmUploadReview,
|
||||
createDataTransfer,
|
||||
createSkillDetail,
|
||||
getFileTreeContextRegion,
|
||||
getFileTreeItem,
|
||||
getMocks,
|
||||
renderSkillDetailPage,
|
||||
resetDetailPageFixture,
|
||||
} from './detail-page.fixture'
|
||||
|
||||
const mocks = getMocks()
|
||||
|
||||
describe('SkillDetailPage uploads', () => {
|
||||
beforeEach(resetDetailPageFixture)
|
||||
|
||||
it('uploads externally dragged files to the root file list', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
const upload = new File(['root'], 'root-guide.md', { type: 'text/markdown' })
|
||||
const { dataTransfer } = createDataTransfer([upload])
|
||||
const contextRegion = getFileTreeContextRegion()
|
||||
|
||||
fireEvent.dragOver(contextRegion, { dataTransfer })
|
||||
|
||||
expect(screen.getByText(/^Upload to/)).toHaveTextContent(/^Upload to root folder$/)
|
||||
|
||||
fireEvent.drop(contextRegion, { dataTransfer })
|
||||
await confirmUploadReview()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledWith(
|
||||
upload,
|
||||
expect.objectContaining({
|
||||
onProgress: expect.any(Function),
|
||||
xhr: expect.any(XMLHttpRequest),
|
||||
}),
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_tool_file',
|
||||
path: 'root-guide.md',
|
||||
tool_file_id: 'tool-file-1',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('reviews backend file checks and applies keep-both, suggestion, and skip decisions', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
content: null,
|
||||
hash: 'report-hash',
|
||||
id: 'report-file',
|
||||
kind: 'file',
|
||||
mime_type: 'application/pdf',
|
||||
path: 'report.pdf',
|
||||
size: 3,
|
||||
storage: 'tool_file',
|
||||
tool_file_id: 'existing-report',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.checkDraftFilesMutationFn.mockResolvedValueOnce({
|
||||
data: {
|
||||
'my notes!.md': {
|
||||
errors: [{ code: 'invalid_filename', message: 'filename is invalid' }],
|
||||
extension: '.md',
|
||||
filename: 'my notes!.md',
|
||||
mime_type: 'text/markdown',
|
||||
path: 'my notes!.md',
|
||||
size: 5,
|
||||
},
|
||||
'report.abcd': {
|
||||
errors: [{ code: 'invalid_file_extension', message: 'extension is invalid' }],
|
||||
extension: '.abcd',
|
||||
filename: 'report.abcd',
|
||||
mime_type: 'application/octet-stream',
|
||||
path: 'report.abcd',
|
||||
size: 4,
|
||||
},
|
||||
'report.pdf': {
|
||||
errors: [{ code: 'file_already_exists', message: 'file already exists' }],
|
||||
extension: '.pdf',
|
||||
filename: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
path: 'report.pdf',
|
||||
size: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeItem('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const uploads = [
|
||||
new File(['pdf'], 'report.pdf', { type: 'application/pdf' }),
|
||||
new File(['notes'], 'my notes!.md', { type: 'text/markdown' }),
|
||||
new File(['bad'], 'report.abcd'),
|
||||
]
|
||||
fireEvent.drop(getFileTreeContextRegion(), {
|
||||
dataTransfer: createDataTransfer(uploads).dataTransfer,
|
||||
})
|
||||
|
||||
const initialUploadButton = await screen.findByRole('button', { name: /uploadFilesButton/ })
|
||||
expect(initialUploadButton).toBeDisabled()
|
||||
expect(screen.getByText(/uploadSkippedGroup/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /uploadKeepBoth/ }))
|
||||
await user.click(screen.getByRole('button', { name: /uploadSuggestion/ }))
|
||||
const resolvedUploadButton = screen.getByRole('button', { name: /uploadFilesButton/ })
|
||||
expect(resolvedUploadButton).toBeEnabled()
|
||||
await user.click(resolvedUploadButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
mocks.saveDraftFileMutationFn.mock.calls.map(([request]) => request.body.path),
|
||||
).toEqual(expect.arrayContaining(['report-2.pdf', 'my-notes.md']))
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads externally dragged files to the highlighted folder', async () => {
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'directory-1',
|
||||
path: 'references',
|
||||
kind: 'directory',
|
||||
storage: 'text',
|
||||
mime_type: null,
|
||||
content: null,
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'directory-hash',
|
||||
},
|
||||
],
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
const folder = await waitFor(() => getFileTreeItem('references'))
|
||||
const upload = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
const { dataTransfer } = createDataTransfer([upload])
|
||||
fireEvent.dragOver(folder.closest('li')!, { dataTransfer })
|
||||
|
||||
expect(screen.getByText(/^Upload to/)).toHaveTextContent(/^Upload to references$/)
|
||||
|
||||
fireEvent.drop(folder.closest('li')!, { dataTransfer })
|
||||
await confirmUploadReview()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledWith(
|
||||
upload,
|
||||
expect.objectContaining({
|
||||
onProgress: expect.any(Function),
|
||||
xhr: expect.any(XMLHttpRequest),
|
||||
}),
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_tool_file',
|
||||
path: 'references/guide.md',
|
||||
tool_file_id: 'tool-file-1',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels an active file upload from the upload status panel', async () => {
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'directory-1',
|
||||
path: 'references',
|
||||
kind: 'directory',
|
||||
storage: 'text',
|
||||
mime_type: null,
|
||||
content: null,
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'directory-hash',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.uploadSkillFile.mockImplementation(() => new Promise(() => undefined))
|
||||
const abortSpy = vi.spyOn(XMLHttpRequest.prototype, 'abort').mockImplementation(() => undefined)
|
||||
renderSkillDetailPage()
|
||||
|
||||
const folder = await waitFor(() => getFileTreeItem('references'))
|
||||
const upload = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
fireEvent.drop(folder.closest('li')!, {
|
||||
dataTransfer: createDataTransfer([upload]).dataTransfer,
|
||||
})
|
||||
await confirmUploadReview()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(abortSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries failed file uploads from the upload status panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'directory-1',
|
||||
path: 'references',
|
||||
kind: 'directory',
|
||||
storage: 'text',
|
||||
mime_type: null,
|
||||
content: null,
|
||||
tool_file_id: null,
|
||||
size: 0,
|
||||
hash: 'directory-hash',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.uploadSkillFile
|
||||
.mockRejectedValueOnce(new Error('network timeout'))
|
||||
.mockResolvedValueOnce({
|
||||
id: 'tool-file-retry',
|
||||
name: 'guide.md',
|
||||
mime_type: 'text/markdown',
|
||||
size: 5,
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
const folder = await waitFor(() => getFileTreeItem('references'))
|
||||
const upload = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
fireEvent.drop(folder.closest('li')!, {
|
||||
dataTransfer: createDataTransfer([upload]).dataTransfer,
|
||||
})
|
||||
await confirmUploadReview()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.uploadFilesFailedStatus:{"count":1}',
|
||||
)
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /viewUploadErrors/ }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.retry' })[0]!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
operation: 'upsert_tool_file',
|
||||
path: 'references/guide.md',
|
||||
tool_file_id: 'tool-file-retry',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('offers replace, keep-both, and skip when a file appears during upload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const initialDetail = createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
content: null,
|
||||
hash: 'directory-hash',
|
||||
id: 'directory-1',
|
||||
kind: 'directory',
|
||||
mime_type: null,
|
||||
path: 'references',
|
||||
size: 0,
|
||||
storage: 'text',
|
||||
tool_file_id: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.skillDetail = initialDetail
|
||||
mocks.saveDraftFileMutationFn
|
||||
.mockImplementationOnce(async () => {
|
||||
mocks.skillDetail = createSkillDetail({
|
||||
updated_at: initialDetail.updated_at + 1,
|
||||
files: [
|
||||
...initialDetail.files!,
|
||||
{
|
||||
content: null,
|
||||
hash: 'late-guide-hash',
|
||||
id: 'late-guide',
|
||||
kind: 'file',
|
||||
mime_type: 'text/markdown',
|
||||
path: 'references/guide.md',
|
||||
size: 5,
|
||||
storage: 'tool_file',
|
||||
tool_file_id: 'late-guide-tool-file',
|
||||
},
|
||||
],
|
||||
})
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
code: 'skill_conflict',
|
||||
message: 'skill has been modified by another user',
|
||||
}),
|
||||
{ status: 409 },
|
||||
)
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
mocks.skillDetail = createSkillDetail({ updated_at: initialDetail.updated_at + 2 })
|
||||
return mocks.skillDetail
|
||||
})
|
||||
renderSkillDetailPage()
|
||||
|
||||
const folder = await waitFor(() => getFileTreeItem('references'))
|
||||
const upload = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
fireEvent.drop(folder.closest('li')!, {
|
||||
dataTransfer: createDataTransfer([upload]).dataTransfer,
|
||||
})
|
||||
await confirmUploadReview()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /viewUploadErrors/ })).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /viewUploadErrors/ }))
|
||||
expect(screen.getByText(/uploadLateConflict/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /uploadReplace/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /uploadSkip/ })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /uploadKeepBoth/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.uploadSkillFile).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ path: 'references/guide-2.md' }),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
848
web/features/skills/__tests__/detail-page.fixture.tsx
Normal file
848
web/features/skills/__tests__/detail-page.fixture.tsx
Normal file
@ -0,0 +1,848 @@
|
||||
import type {
|
||||
SkillDetailResponse,
|
||||
SkillReferenceResponse,
|
||||
SkillVersionResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { vi } from 'vite-plus/test'
|
||||
import { SkillDetailPage } from '../detail/page'
|
||||
|
||||
export const primaryModifier = detectPlatform() === 'mac' ? { metaKey: true } : { ctrlKey: true }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteSkillMutationFn: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
downloadBlob: vi.fn(),
|
||||
duplicateSkillMutationFn: vi.fn(),
|
||||
fetchSkillArchiveBlob: vi.fn(),
|
||||
fetchSkillFileBlob: vi.fn(),
|
||||
checkDraftFilesMutationFn: vi.fn(),
|
||||
publishSkillMutationFn: vi.fn(),
|
||||
publishSkillMutationOptions: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
restoreSkillMutationFn: vi.fn(),
|
||||
saveDraftFileMutationFn: vi.fn(),
|
||||
sendSkillAssistMessage: vi.fn(),
|
||||
defaultTextGenerationModel: undefined as
|
||||
| { provider: { provider: string }; model: string }
|
||||
| undefined,
|
||||
skillDetail: undefined as SkillDetailResponse | undefined,
|
||||
skillDetailGetFn: vi.fn(),
|
||||
skillDetailKey: vi.fn((_options: unknown): unknown[] => ['skill-detail']),
|
||||
skillDetailQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
agentSkillBindingsKey: vi.fn((_options: unknown): unknown[] => ['agent-skill-bindings']),
|
||||
skillListKey: vi.fn((_options: unknown): unknown[] => ['skills']),
|
||||
skillTags: [] as { count: number; tag: string }[],
|
||||
skillMetadataMutationFn: vi.fn(),
|
||||
skillReferencesQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
skillTagsKey: vi.fn((_options: unknown): unknown[] => ['skill-tags']),
|
||||
skillTagsQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
skillVersionsKey: vi.fn((_options: unknown): unknown[] => ['skill-versions']),
|
||||
skillVersionsQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
skillVersionDetailQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
textGenerationModelList: [] as {
|
||||
provider: string
|
||||
status: string
|
||||
models: { model: string; status: string }[]
|
||||
}[],
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
uploadSkillFile: vi.fn(),
|
||||
versionDeleteMutationFn: vi.fn(),
|
||||
versionPatchMutationFn: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
info: mocks.toastInfo,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: mocks.copyToClipboard,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({
|
||||
content,
|
||||
customComponents,
|
||||
}: {
|
||||
content: string
|
||||
customComponents?: {
|
||||
a?: (props: { children: string; href: string }) => ReactNode
|
||||
}
|
||||
}) => {
|
||||
const reference = content.match(/\[([^\]]+)\]\(<([^>\n]+)>\)/)
|
||||
if (reference && customComponents?.a) {
|
||||
return <div>{customComponents.a({ children: reference[1]!, href: reference[2]! })}</div>
|
||||
}
|
||||
|
||||
return <div>{content}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ icon }: { icon?: string }) => <span>{icon}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/main-nav/components/account-section', () => ({
|
||||
default: ({ compact = false }: { compact?: boolean }) => (
|
||||
<button type="button" aria-label={compact ? 'compact-account-section' : 'account-section'}>
|
||||
Current account
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/main-nav/components/help-menu', () => ({
|
||||
default: () => (
|
||||
<button type="button" aria-label="help-menu">
|
||||
Help
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({
|
||||
data: mocks.defaultTextGenerationModel,
|
||||
}),
|
||||
useModelList: () => ({
|
||||
data: mocks.textGenerationModelList,
|
||||
isLoading: false,
|
||||
}),
|
||||
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
|
||||
currentProvider: mocks.textGenerationModelList[0],
|
||||
currentModel: mocks.textGenerationModelList[0]?.models[0],
|
||||
activeTextGenerationModelList: mocks.textGenerationModelList,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
ModelSelector: () => <button type="button">model-settings</button>,
|
||||
SplitModelSelector: () => <button type="button">model-settings</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/use-common')>()),
|
||||
useModelParameterRules: () => ({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ onChange, value }: { onChange?: (value: string) => void; value: string }) => (
|
||||
<textarea
|
||||
aria-label="code-editor"
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'just now',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: () => '2026-07-21 12:00',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-management-modal', () => ({
|
||||
TagManagementModal: ({ show }: { show: boolean }) =>
|
||||
show ? <div role="dialog">common.tag.manageTags</div> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: ReactNode; href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mocks.routerPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mocks.downloadBlob,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
workspaces: {
|
||||
current: {
|
||||
skills: {
|
||||
bySkillId: {
|
||||
get: mocks.skillDetailGetFn,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
workspaces: {
|
||||
current: {
|
||||
agents: {
|
||||
byAgentId: {
|
||||
skills: {
|
||||
get: {
|
||||
key: mocks.agentSkillBindingsKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
get: {
|
||||
key: mocks.skillListKey,
|
||||
},
|
||||
tags: {
|
||||
get: {
|
||||
key: mocks.skillTagsKey,
|
||||
queryOptions: mocks.skillTagsQueryOptions,
|
||||
},
|
||||
},
|
||||
bySkillId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }),
|
||||
},
|
||||
duplicate: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.duplicateSkillMutationFn }),
|
||||
},
|
||||
},
|
||||
get: {
|
||||
key: mocks.skillDetailKey,
|
||||
queryOptions: mocks.skillDetailQueryOptions,
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.skillMetadataMutationFn }),
|
||||
},
|
||||
publish: {
|
||||
post: {
|
||||
mutationOptions: (options?: unknown) => {
|
||||
mocks.publishSkillMutationOptions(options)
|
||||
return { mutationFn: mocks.publishSkillMutationFn }
|
||||
},
|
||||
},
|
||||
},
|
||||
references: {
|
||||
get: {
|
||||
queryOptions: mocks.skillReferencesQueryOptions,
|
||||
},
|
||||
},
|
||||
restore: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.restoreSkillMutationFn }),
|
||||
},
|
||||
},
|
||||
files: {
|
||||
check: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.checkDraftFilesMutationFn }),
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.saveDraftFileMutationFn }),
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
get: {
|
||||
key: mocks.skillVersionsKey,
|
||||
queryOptions: mocks.skillVersionsQueryOptions,
|
||||
},
|
||||
byVersionId: {
|
||||
get: {
|
||||
queryOptions: mocks.skillVersionDetailQueryOptions,
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.versionPatchMutationFn }),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.versionDeleteMutationFn }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../permissions', () => ({
|
||||
useSkillPermissions: () => ({ canDelete: true, canEdit: true, canPublish: true }),
|
||||
}))
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
fetchSkillArchiveBlob: mocks.fetchSkillArchiveBlob,
|
||||
fetchSkillFileBlob: mocks.fetchSkillFileBlob,
|
||||
sendSkillAssistMessage: mocks.sendSkillAssistMessage,
|
||||
uploadSkillFile: mocks.uploadSkillFile,
|
||||
}))
|
||||
|
||||
export function createSkillDetail(
|
||||
overrides: Partial<SkillDetailResponse> = {},
|
||||
): SkillDetailResponse {
|
||||
return {
|
||||
id: 'skill-1',
|
||||
name: 'github-actions-failure-debugging',
|
||||
display_name: 'Untitled skill',
|
||||
icon: '📄',
|
||||
description: 'Guide for debugging failing GitHub Actions workflows.',
|
||||
tags: [],
|
||||
name_manually_edited: true,
|
||||
visibility: 'workspace',
|
||||
latest_published_version_id: 'version-1',
|
||||
latest_published_version_number: 1,
|
||||
latest_published_at: 1784638400,
|
||||
reference_count: 0,
|
||||
created_by: 'user-1',
|
||||
created_by_name: 'Fate',
|
||||
updated_by: 'user-1',
|
||||
updated_by_name: 'Fate',
|
||||
created_at: 1784631405,
|
||||
updated_at: 1784638487,
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: github-actions-failure-debugging\ndescription: Guide for debugging failing GitHub Actions workflows.\nmetadata:\n display-name: Untitled skill\n---\n# GitHub Actions Failure Debugging\n',
|
||||
tool_file_id: null,
|
||||
size: 180,
|
||||
hash: 'hash-1',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultSkillDraftDetail(overrides: Partial<SkillDetailResponse> = {}) {
|
||||
return createSkillDetail({
|
||||
name: 'untitled-skill-74d8b044',
|
||||
display_name: 'Untitled skill',
|
||||
description: 'Describe what this Skill does and when an Agent should use it.',
|
||||
latest_published_version_id: null,
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: 'SKILL.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content:
|
||||
'---\nname: untitled-skill-74d8b044\ndescription: Describe what this Skill does and when an Agent should use it.\nmetadata:\n display-name: Untitled skill\n---\n# Untitled skill\n\nDescribe what this Skill does, when an Agent should use it, and any step-by-step instructions it must follow.\n',
|
||||
tool_file_id: null,
|
||||
size: 248,
|
||||
hash: 'hash-1',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
export function createFileTabSkillDetail() {
|
||||
return createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-readme',
|
||||
path: 'README.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# README',
|
||||
tool_file_id: null,
|
||||
size: 8,
|
||||
hash: 'hash-readme',
|
||||
},
|
||||
{
|
||||
id: 'file-prompt',
|
||||
path: 'prompt.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# Prompt',
|
||||
tool_file_id: null,
|
||||
size: 8,
|
||||
hash: 'hash-prompt',
|
||||
},
|
||||
{
|
||||
id: 'file-notes',
|
||||
path: 'notes.txt',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/plain',
|
||||
content: 'Notes',
|
||||
tool_file_id: null,
|
||||
size: 5,
|
||||
hash: 'hash-notes',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function createReferencePickerSkillDetail() {
|
||||
return createSkillDetail({
|
||||
files: [
|
||||
...createSkillDetail().files!,
|
||||
{
|
||||
id: 'file-readme',
|
||||
path: 'README.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# README',
|
||||
tool_file_id: null,
|
||||
size: 8,
|
||||
hash: 'hash-readme',
|
||||
},
|
||||
{
|
||||
id: 'file-guide',
|
||||
path: 'docs/guide.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# Guide',
|
||||
tool_file_id: null,
|
||||
size: 7,
|
||||
hash: 'hash-guide',
|
||||
},
|
||||
{
|
||||
id: 'file-reference',
|
||||
path: 'docs/reference.md',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/markdown',
|
||||
content: '# Reference',
|
||||
tool_file_id: null,
|
||||
size: 11,
|
||||
hash: 'hash-reference',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function createSkillVersion(
|
||||
overrides: Partial<SkillVersionResponse> = {},
|
||||
): SkillVersionResponse {
|
||||
return {
|
||||
id: 'version-1',
|
||||
skill_id: 'skill-1',
|
||||
version_number: 1,
|
||||
version_name: 'Initial version',
|
||||
publish_note: 'Original instructions',
|
||||
hash_code: 'hash-code-1',
|
||||
archive_size: 180,
|
||||
published_by: 'user-1',
|
||||
published_by_name: 'Fate',
|
||||
created_at: 1784638400,
|
||||
is_latest: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentReference(
|
||||
overrides: Partial<SkillReferenceResponse> = {},
|
||||
): 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,
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSkillDetailPage({
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
}),
|
||||
strict = false,
|
||||
}: {
|
||||
queryClient?: QueryClient
|
||||
strict?: boolean
|
||||
} = {}) {
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{strict ? (
|
||||
<StrictMode>
|
||||
<SkillDetailPage skillId="skill-1" />
|
||||
</StrictMode>
|
||||
) : (
|
||||
<SkillDetailPage skillId="skill-1" />
|
||||
)}
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
queryClient,
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuilderAttachmentInput(container: HTMLElement) {
|
||||
const inputs = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="file"]'))
|
||||
return inputs.at(-1) ?? null
|
||||
}
|
||||
|
||||
export function getSourceEditor() {
|
||||
const editors = screen.getAllByRole('textbox')
|
||||
const sourceEditor = editors.find(
|
||||
(editor): editor is HTMLTextAreaElement =>
|
||||
editor instanceof HTMLTextAreaElement &&
|
||||
editor.value.includes('name: github-actions-failure-debugging'),
|
||||
)
|
||||
|
||||
if (!sourceEditor) throw new Error('source editor not found')
|
||||
|
||||
return sourceEditor
|
||||
}
|
||||
|
||||
export function getLiveMarkdownEditor() {
|
||||
const liveEditor = screen
|
||||
.getAllByRole('textbox')
|
||||
.find(
|
||||
(editor): editor is HTMLDivElement =>
|
||||
editor instanceof HTMLDivElement && editor.isContentEditable,
|
||||
)
|
||||
|
||||
if (!liveEditor) throw new Error('live markdown editor not found')
|
||||
|
||||
return liveEditor
|
||||
}
|
||||
|
||||
export function placeCaretAtEnd(element: HTMLElement) {
|
||||
const selection = element.ownerDocument.getSelection()
|
||||
const range = element.ownerDocument.createRange()
|
||||
range.selectNodeContents(element)
|
||||
range.collapse(false)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
export function getFileTreeItem(path: string) {
|
||||
const fileButton = document.querySelector(`[title="${path}"]`)
|
||||
const treeItem = fileButton?.closest('[data-skill-file-tree-item]')
|
||||
if (!(treeItem instanceof HTMLElement)) throw new Error(`file tree item not found: ${path}`)
|
||||
|
||||
return treeItem
|
||||
}
|
||||
|
||||
export function getReferencePicker() {
|
||||
const picker = document.querySelector<HTMLElement>('div.fixed.z-50')
|
||||
if (!picker) throw new Error('reference picker not found')
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
export function getReferencePickerButton(name: string | RegExp) {
|
||||
return within(getReferencePicker()).getByRole('button', { name })
|
||||
}
|
||||
|
||||
export function preserveDraftFilesOnSave() {
|
||||
mocks.saveDraftFileMutationFn.mockImplementation(
|
||||
async (input: { body: { content?: string; operation: string; path: string } }) => {
|
||||
const currentDetail = mocks.skillDetail ?? createSkillDetail()
|
||||
const nextFiles =
|
||||
currentDetail.files?.map((file) =>
|
||||
file.path === input.body.path
|
||||
? {
|
||||
...file,
|
||||
content: input.body.content ?? file.content,
|
||||
hash: `${file.hash ?? 'hash'}-saved`,
|
||||
}
|
||||
: file,
|
||||
) ?? []
|
||||
mocks.skillDetail = {
|
||||
...currentDetail,
|
||||
files: nextFiles,
|
||||
updated_at: 1784638490,
|
||||
}
|
||||
|
||||
return mocks.skillDetail
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function getFileTreeButton(path: string) {
|
||||
const fileButton = document.querySelector(`[title="${path}"]`)
|
||||
if (!(fileButton instanceof HTMLButtonElement)) throw new Error(`file button not found: ${path}`)
|
||||
|
||||
return fileButton
|
||||
}
|
||||
|
||||
export function getFileTreeContextRegion() {
|
||||
const region = document.querySelector('[data-skill-file-tree-context-region]')
|
||||
if (!(region instanceof HTMLElement)) throw new Error('file tree context region not found')
|
||||
|
||||
return region
|
||||
}
|
||||
|
||||
export function getFileTabButton(path: string) {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(`button[title="${path}"]`),
|
||||
).find((candidate) => !candidate.closest('[data-skill-file-tree-item]'))
|
||||
if (!button) throw new Error(`file tab not found: ${path}`)
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
export function createDataTransfer(files: File[] = []) {
|
||||
const data = new Map<string, string>()
|
||||
const types = files.length > 0 ? ['Files'] : []
|
||||
const setDragImage = vi.fn()
|
||||
|
||||
return {
|
||||
dataTransfer: {
|
||||
dropEffect: 'none',
|
||||
effectAllowed: 'uninitialized',
|
||||
files,
|
||||
getData: (type: string) => data.get(type) ?? '',
|
||||
setData: (type: string, value: string) => {
|
||||
data.set(type, value)
|
||||
if (!types.includes(type)) types.push(type)
|
||||
},
|
||||
setDragImage,
|
||||
types,
|
||||
} as unknown as DataTransfer,
|
||||
setDragImage,
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFileTreeActions(user: ReturnType<typeof userEvent.setup>, path: string) {
|
||||
const treeItem = getFileTreeItem(path)
|
||||
await user.click(within(treeItem).getByRole('button', { name: 'common.operation.more' }))
|
||||
}
|
||||
|
||||
export async function openRootCreateMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
const triggers = Array.from(document.querySelectorAll('aside .i-ri-add-line'))
|
||||
.map((icon) => icon.closest('button'))
|
||||
.filter((button): button is HTMLButtonElement => button instanceof HTMLButtonElement)
|
||||
const trigger = triggers.at(-1)
|
||||
if (!(trigger instanceof HTMLButtonElement)) throw new Error('root create menu trigger not found')
|
||||
|
||||
await user.click(trigger)
|
||||
}
|
||||
|
||||
export async function confirmUploadReview() {
|
||||
const uploadButton = await screen.findByRole('button', { name: /uploadFilesButton/ })
|
||||
fireEvent.click(uploadButton)
|
||||
}
|
||||
|
||||
export async function openVersionRowActions(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
versionName: string,
|
||||
) {
|
||||
const versionText = await screen.findByText(versionName)
|
||||
const versionRow = versionText.closest('li')
|
||||
if (!(versionRow instanceof HTMLElement)) throw new Error(`version row not found: ${versionName}`)
|
||||
const buttons = within(versionRow).getAllByRole('button')
|
||||
const actionButton = buttons.at(-1)
|
||||
if (!actionButton) throw new Error(`version row action not found: ${versionName}`)
|
||||
|
||||
await user.click(actionButton)
|
||||
}
|
||||
|
||||
export function getMocks() {
|
||||
return mocks
|
||||
}
|
||||
|
||||
export function resetDetailPageFixture() {
|
||||
vi.useRealTimers()
|
||||
vi.resetAllMocks()
|
||||
mocks.defaultTextGenerationModel = {
|
||||
provider: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
},
|
||||
model: 'gpt-5.5',
|
||||
}
|
||||
mocks.textGenerationModelList = [
|
||||
{
|
||||
provider: 'langgenius/openai/openai',
|
||||
status: 'active',
|
||||
models: [
|
||||
{
|
||||
model: 'gpt-5.5',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
mocks.skillDetail = createSkillDetail()
|
||||
mocks.skillDetailGetFn.mockImplementation(async () => mocks.skillDetail)
|
||||
mocks.skillDetailKey.mockImplementation((options) => ['skill-detail', options])
|
||||
mocks.agentSkillBindingsKey.mockImplementation((options) => ['agent-skill-bindings', options])
|
||||
mocks.skillVersionsKey.mockImplementation((options) => ['skill-versions', options])
|
||||
mocks.skillListKey.mockImplementation((options) => ['skills', options])
|
||||
mocks.skillTags = [
|
||||
{ count: 3, tag: 'Search' },
|
||||
{ count: 2, tag: 'Productivity' },
|
||||
{ count: 1, tag: 'Utilities' },
|
||||
]
|
||||
mocks.skillTagsKey.mockImplementation((options) => ['skill-tags', options])
|
||||
mocks.skillTagsQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-tags'],
|
||||
queryFn: async () => ({
|
||||
data: [],
|
||||
}),
|
||||
}))
|
||||
mocks.skillDetailQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-detail', options],
|
||||
queryFn: async () => mocks.skillDetail,
|
||||
}))
|
||||
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-versions', options],
|
||||
queryFn: async () => ({
|
||||
data: [],
|
||||
}),
|
||||
}))
|
||||
mocks.skillVersionDetailQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-version-detail', options],
|
||||
queryFn: async () => ({
|
||||
...mocks.skillDetail,
|
||||
files: [],
|
||||
}),
|
||||
}))
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [],
|
||||
}),
|
||||
}))
|
||||
mocks.skillTagsQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['skill-tags'],
|
||||
queryFn: async () => ({
|
||||
data: mocks.skillTags,
|
||||
}),
|
||||
}))
|
||||
mocks.saveDraftFileMutationFn.mockImplementation(
|
||||
async (input: { body: { content?: string; operation: string; path: string } }) => {
|
||||
if (input.body.operation !== 'upsert_text') {
|
||||
const nextDetail = createSkillDetail({
|
||||
updated_at: 1784638490,
|
||||
})
|
||||
mocks.skillDetail = {
|
||||
...nextDetail,
|
||||
files: mocks.skillDetail?.files ?? nextDetail.files,
|
||||
}
|
||||
return mocks.skillDetail
|
||||
}
|
||||
|
||||
const nextDetail = createSkillDetail({
|
||||
display_name: input.body.content?.includes('display-name: 333333333')
|
||||
? '333333333'
|
||||
: 'Untitled skill',
|
||||
updated_at: 1784638490,
|
||||
})
|
||||
const nextFiles = nextDetail.files ?? []
|
||||
nextFiles[0] = {
|
||||
...nextFiles[0]!,
|
||||
content: input.body.content ?? '',
|
||||
}
|
||||
nextDetail.files = nextFiles
|
||||
mocks.skillDetail = nextDetail
|
||||
return nextDetail
|
||||
},
|
||||
)
|
||||
mocks.skillMetadataMutationFn.mockImplementation(
|
||||
async (input: { body: { display_name?: string; tags?: string[] } }) => {
|
||||
const nextDetail = createSkillDetail({
|
||||
display_name: input.body.display_name ?? 'Untitled skill',
|
||||
tags: input.body.tags ?? mocks.skillDetail?.tags ?? [],
|
||||
updated_at: 1784638491,
|
||||
})
|
||||
mocks.skillDetail = {
|
||||
...nextDetail,
|
||||
files: mocks.skillDetail?.files ?? nextDetail.files,
|
||||
}
|
||||
return nextDetail
|
||||
},
|
||||
)
|
||||
mocks.publishSkillMutationFn.mockImplementation(async () => {
|
||||
const version = {
|
||||
id: 'version-2',
|
||||
version_number: 2,
|
||||
version_name: '',
|
||||
publish_note: '',
|
||||
hash_code: 'hash-code',
|
||||
archive_size: 180,
|
||||
published_by: 'user-1',
|
||||
published_by_name: 'Fate',
|
||||
created_at: 1784638492,
|
||||
is_latest: true,
|
||||
}
|
||||
mocks.skillDetail = mocks.skillDetail
|
||||
? {
|
||||
...mocks.skillDetail,
|
||||
latest_published_at: version.created_at,
|
||||
latest_published_version_id: version.id,
|
||||
latest_published_version_number: version.version_number,
|
||||
}
|
||||
: mocks.skillDetail
|
||||
return version
|
||||
})
|
||||
mocks.restoreSkillMutationFn.mockResolvedValue({})
|
||||
mocks.versionPatchMutationFn.mockResolvedValue({})
|
||||
mocks.versionDeleteMutationFn.mockResolvedValue({})
|
||||
mocks.sendSkillAssistMessage.mockResolvedValue(undefined)
|
||||
mocks.checkDraftFilesMutationFn.mockImplementation(
|
||||
async (input: {
|
||||
body: {
|
||||
files?: Array<{
|
||||
filename: string
|
||||
mime_type?: string | null
|
||||
path?: string | null
|
||||
size: number
|
||||
}>
|
||||
}
|
||||
}) => ({
|
||||
data: Object.fromEntries(
|
||||
(input.body.files ?? []).map((file) => [
|
||||
file.filename,
|
||||
{
|
||||
errors: [],
|
||||
extension: file.filename.includes('.') ? `.${file.filename.split('.').at(-1)}` : '',
|
||||
filename: file.filename,
|
||||
mime_type: file.mime_type ?? 'application/octet-stream',
|
||||
path: file.path ?? file.filename,
|
||||
size: file.size,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
)
|
||||
mocks.uploadSkillFile.mockResolvedValue({
|
||||
id: 'tool-file-1',
|
||||
name: 'guide.md',
|
||||
mime_type: 'text/markdown',
|
||||
size: 10,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user