test(web): migrate marketplace home specs to browser (#45952)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-10 08:58:46 +00:00
1 parent 7bca398954
commit 116a990765
17 files changed
+1231 -2544

No files matched your search

-5
View File
@@ -2923,11 +2923,6 @@
"count": 4
}
},
"web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx": {
"typescript/no-deprecated": {
"count": 4
}
},
"web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
@@ -0,0 +1,106 @@
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import CatalogLanguagesFilter from '../catalog-languages-filter'
import CatalogTagsFilter from '../catalog-tags-filter'
vi.mock('#i18n', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...createReactI18nextMock({
clearSearch: 'Clear {{label}}',
'marketplace.languages': 'Languages',
'marketplace.searchFilterLanguage': 'Search languages',
'pluginTags.allTags': 'All tags',
'pluginTags.clearSelectedTags': 'Clear {{tags}}',
'pluginTags.searchTags': 'Search tags',
}),
useLocale: () => 'en-US',
}
})
vi.mock('react-i18next', async (importOriginal) => {
const original = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...original,
...createReactI18nextMock({
'pluginTags.allTags': 'All tags',
'pluginTags.clearSelectedTags': 'Clear {{tags}}',
'pluginTags.searchTags': 'Search tags',
'pluginTags.tags.agent': 'Agent',
'pluginTags.tags.rag': 'RAG',
'pluginTags.tags.search': 'Search',
}),
}
})
async function renderFilters(searchParams = '') {
const { wrapper: UrlState, onUrlUpdate } = createNuqsTestWrapper({
hasMemory: true,
searchParams,
})
const screen = await render(
<UrlState>
<div aria-label="Catalog filters" role="group">
<CatalogLanguagesFilter />
<CatalogTagsFilter />
</div>
</UrlState>,
)
return { onUrlUpdate, screen }
}
describe('Marketplace catalog filters', () => {
beforeEach(async () => {
await page.viewport(1000, 700)
vi.clearAllMocks()
})
it('keeps selected languages visible and restores focus after clearing', async () => {
const { onUrlUpdate, screen } = await renderFilters()
const trigger = screen.getByRole('button', { name: 'Languages' })
await trigger.click()
const search = screen.getByRole('searchbox', { name: 'Search languages' })
await expect.element(search).toHaveFocus()
await search.fill('中文')
await screen.getByRole('checkbox', { name: '中文' }).click()
await expect.element(screen.getByRole('button', { name: '中文' })).toBeVisible()
await expect
.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages'))
.toBe('zh-Hans')
await expect.element(screen.getByRole('checkbox', { name: '中文' })).toBeChecked()
await userEvent.keyboard('{Escape}')
const clear = screen.getByRole('button', { name: 'Clear 中文' })
await clear.click()
await expect.element(screen.getByRole('button', { name: 'Languages' })).toHaveFocus()
await expect
.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('languages'))
.toBe(false)
})
it('filters real tag options, preserves multiple selections, and restores focus after clearing', async () => {
const { onUrlUpdate, screen } = await renderFilters()
const trigger = screen.getByRole('button', { name: 'All tags' })
await trigger.click()
const search = screen.getByRole('searchbox', { name: 'Search tags' })
await expect.element(search).toHaveFocus()
await search.fill('agent')
await screen.getByRole('checkbox', { name: 'Agent' }).click()
await expect.element(screen.getByRole('button', { name: 'Agent' })).toBeVisible()
await expect
.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags'))
.toBe('agent')
await expect.element(screen.getByRole('checkbox', { name: 'Agent' })).toBeChecked()
await userEvent.keyboard('{Escape}')
await screen.getByRole('button', { name: 'Clear Agent' }).click()
await expect.element(screen.getByRole('button', { name: 'All tags' })).toHaveFocus()
await expect.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('tags')).toBe(false)
})
})
@@ -1,32 +0,0 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { renderWithNuqs } from '@/test/nuqs-testing'
import CatalogLanguagesFilter from '../catalog-languages-filter'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string, options?: { ns?: string }) =>
options?.ns ? `${options.ns}.${key}` : key,
),
}),
}
})
describe('CatalogLanguagesFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes selected languages into the URL', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(<CatalogLanguagesFilter />)
await user.click(screen.getByRole('button', { name: 'plugin.marketplace.languages' }))
await user.click(screen.getByRole('checkbox', { name: '中文' }))
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages')).toBe('zh-Hans')
})
})
})
@@ -1,47 +0,0 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { renderWithNuqs } from '@/test/nuqs-testing'
import CatalogTagsFilter from '../catalog-tags-filter'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string, options?: { ns?: string }) =>
options?.ns ? `${options.ns}.${key}` : key,
),
}),
}
})
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
],
tagsMap: {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
},
}),
}))
describe('CatalogTagsFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes selected tags into the URL', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(<CatalogTagsFilter />)
await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags')).toBe('agent')
})
})
})
@@ -0,0 +1,236 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { Plugin } from '@/app/components/plugins/types'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import EmbeddedMarketplaceSearch from '../embedded-marketplace-search'
const boundary = vi.hoisted(() => ({
push: vi.fn(),
searchPlugins: vi.fn(),
searchTemplates: vi.fn(),
}))
vi.mock('#i18n', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...createReactI18nextMock({
clearSearch: 'Clear search',
loading: 'Loading',
'marketplace.home.plugins': 'Plugins',
'marketplace.home.searchPlaceholder': 'Search plugins or templates',
'marketplace.home.templates': 'Templates',
'marketplace.loadError': 'Failed to load. Please try again.',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
}),
useLocale: () => 'en-US',
}
})
vi.mock('react-i18next', async (importOriginal) => {
const original = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...original,
...createReactI18nextMock({
clearSearch: 'Clear search',
loading: 'Loading',
'marketplace.home.plugins': 'Plugins',
'marketplace.home.templates': 'Templates',
'marketplace.loadError': 'Failed to load. Please try again.',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
}),
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: boundary.push }),
}))
vi.mock('@/service/marketplace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/marketplace')>()
return {
...actual,
marketplaceQuery: {
...actual.marketplaceQuery,
searchAdvanced: {
...actual.marketplaceQuery.searchAdvanced,
queryOptions: (
options: Parameters<typeof actual.marketplaceQuery.searchAdvanced.queryOptions>[0],
) => ({
...actual.marketplaceQuery.searchAdvanced.queryOptions(options),
queryFn: () => boundary.searchPlugins(options.input),
}),
},
templateSearch: {
...actual.marketplaceQuery.templateSearch,
queryOptions: (
options: Parameters<typeof actual.marketplaceQuery.templateSearch.queryOptions>[0],
) => ({
...actual.marketplaceQuery.templateSearch.queryOptions(options),
queryFn: () => boundary.searchTemplates(options.input),
}),
},
},
}
})
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }),
}),
}
})
// Keep the real dialog lifecycle while preventing its iframe from loading the external Marketplace.
vi.mock('@/features/marketplace/links', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/features/marketplace/links')>()
const toBlank = (url: string) =>
`about:blank${new URL(url, 'https://marketplace.example.test').search}`
return {
...actual,
marketplaceLinks: {
...actual.marketplaceLinks,
plugin: (...args: Parameters<typeof actual.marketplaceLinks.plugin>) =>
toBlank(actual.marketplaceLinks.plugin(...args)),
template: (...args: Parameters<typeof actual.marketplaceLinks.template>) =>
toBlank(actual.marketplaceLinks.template(...args)),
},
}
})
const plugin = {
type: 'plugin',
org: 'langgenius',
name: 'google-search',
plugin_id: 'langgenius/google-search',
latest_package_identifier: 'langgenius/google-search:1.0.0@test',
label: { 'en-US': 'Google Search' },
brief: { 'en-US': 'Search the web from your workflow.' },
category: 'tool',
} as Plugin
const template = {
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_unique_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
} as MarketplaceTemplate
async function renderSearch(edition: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' = 'CLOUD') {
const { wrapper: UrlState, onUrlUpdate } = createNuqsTestWrapper({ hasMemory: true })
const { queryClient, wrapper: ConsoleProvider } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: edition },
})
seedModelProviderQueries(queryClient)
const screen = await render(
<ConsoleProvider>
<UrlState>
<EmbeddedMarketplaceSearch />
</UrlState>
</ConsoleProvider>,
)
return { onUrlUpdate, screen }
}
describe('Embedded Marketplace search', () => {
beforeEach(async () => {
await page.viewport(1000, 800)
onTestFinished(guardUnexpectedFetch())
vi.clearAllMocks()
boundary.searchPlugins.mockResolvedValue({ data: { plugins: [plugin], total: 1 } })
boundary.searchTemplates.mockResolvedValue({ data: { templates: [template], total: 1 } })
})
it('opens both Cloud result dialogs, closes them, and leaves catalog filters untouched', async () => {
const { onUrlUpdate, screen } = await renderSearch()
const search = screen.getByRole('combobox', { name: 'Search plugins or templates' })
await search.fill('search')
await screen.getByRole('option', { name: /Google Search/ }).click()
const pluginDialog = screen.getByRole('dialog', { name: /Google Search/ })
await expect.element(pluginDialog).toBeVisible()
await expect
.element(screen.getByTitle(/^Google Search/))
.toHaveAttribute('src', expect.stringContaining('installed=false'))
await pluginDialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(pluginDialog).not.toBeInTheDocument()
await search.fill('legal')
await screen.getByRole('option', { name: /Legal Research Agent/ }).click()
const templateDialog = screen.getByRole('dialog', { name: /Legal Research Agent/ })
await expect.element(templateDialog).toBeVisible()
await templateDialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(templateDialog).not.toBeInTheDocument()
await expect.element(search).toHaveFocus()
expect(onUrlUpdate).not.toHaveBeenCalled()
})
it('uses native Enter submission to filter the catalog instead of opening a result', async () => {
const { onUrlUpdate, screen } = await renderSearch()
const search = screen.getByRole('combobox', { name: 'Search plugins or templates' })
await search.fill('google')
await expect.element(screen.getByRole('option', { name: /Google Search/ })).toBeVisible()
await userEvent.keyboard('{Enter}')
await expect.poll(() => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
await expect.element(search).toHaveValue('google')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
})
it.each([
['COMMUNITY', 'plugin', 'Google Search'],
['ENTERPRISE', 'template', 'Legal Research Agent'],
] as const)(
'opens a %s %s on the official site without changing the catalog',
async (edition, kind, label) => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
boundary.searchPlugins.mockResolvedValue({
data: { plugins: kind === 'plugin' ? [plugin] : [], total: kind === 'plugin' ? 1 : 0 },
})
boundary.searchTemplates.mockResolvedValue({
data: {
templates: kind === 'template' ? [template] : [],
total: kind === 'template' ? 1 : 0,
},
})
const { onUrlUpdate, screen } = await renderSearch(edition)
const search = screen.getByRole('combobox', { name: 'Search plugins or templates' })
await search.fill('search')
const option = screen.getByRole('option', { name: new RegExp(label) })
await expect.element(option).toBeVisible()
if (kind === 'plugin') await option.click()
else {
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{Enter}')
}
expect(open).toHaveBeenCalledOnce()
const opened = new URL(String(open.mock.calls[0]![0]))
expect(opened.origin).toBe('https://marketplace.dify.ai')
expect(opened.pathname).toBe(
kind === 'plugin' ? '/plugin/langgenius/google-search' : '/template/dify/template-1',
)
expect(opened.searchParams.get('source')).toBe(window.location.origin)
await expect.element(search).toHaveValue('search')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
expect(onUrlUpdate).not.toHaveBeenCalled()
},
)
})
@@ -1,226 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import EmbeddedMarketplaceSearch from '../embedded-marketplace-search'
const deploymentState = vi.hoisted(() => ({
deploymentEdition: 'CLOUD' as 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE',
}))
vi.mock('@/features/system-features/state', async () => {
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
return createSystemFeaturesStateModuleMock(() => deploymentState)
})
const { mockPluginSearch, mockTemplateSearch, push } = vi.hoisted(() => ({
push: vi.fn(),
mockPluginSearch: vi.fn(),
mockTemplateSearch: vi.fn(),
}))
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
const translations: Record<string, string> = {
'marketplace.home.searchPlaceholder': 'Search plugins or templates',
'marketplace.home.plugins': 'Plugins',
'marketplace.home.templates': 'Templates',
'marketplace.loadError': 'Failed to load. Please try again.',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
clearSearch: 'Clear search',
loading: 'Loading',
}
return {
useLocale: () => 'en-US',
useTranslation: () => ({
t: withSelectorKey((key: string) => translations[key] ?? key),
}),
}
})
vi.mock('@/service/marketplace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/marketplace')>()
return {
...actual,
marketplaceQuery: {
...actual.marketplaceQuery,
searchAdvanced: {
...actual.marketplaceQuery.searchAdvanced,
queryOptions: (
options: Parameters<typeof actual.marketplaceQuery.searchAdvanced.queryOptions>[0],
) => ({
...actual.marketplaceQuery.searchAdvanced.queryOptions(options),
queryFn: () => mockPluginSearch(),
}),
},
templateSearch: {
...actual.marketplaceQuery.templateSearch,
queryOptions: (
options: Parameters<typeof actual.marketplaceQuery.templateSearch.queryOptions>[0],
) => ({
...actual.marketplaceQuery.templateSearch.queryOptions(options),
queryFn: () => mockTemplateSearch(),
}),
},
},
}
})
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({ installedInfo: {} }),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push }),
}))
vi.mock('../../detail-dialog/use-silent-install', () => ({
useSilentMarketplaceInstall: () => ({ install: vi.fn() }),
}))
// Exercise real dialog/frame message handling without loading external documents.
vi.mock('@/features/marketplace/links', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/features/marketplace/links')>()
const toBlank = (url: string) =>
`about:blank${new URL(url, 'https://marketplace.example.test').search}`
return {
...actual,
marketplaceLinks: {
...actual.marketplaceLinks,
plugin: (...args: Parameters<typeof actual.marketplaceLinks.plugin>) =>
toBlank(actual.marketplaceLinks.plugin(...args)),
template: (...args: Parameters<typeof actual.marketplaceLinks.template>) =>
toBlank(actual.marketplaceLinks.template(...args)),
},
}
})
const plugin = {
type: 'plugin',
org: 'langgenius',
name: 'google-search',
plugin_id: 'langgenius/google-search',
label: { 'en-US': 'Google Search' },
brief: { 'en-US': 'Search the web from your workflow.' },
category: 'tool',
}
const template = {
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_unique_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
}
const renderSearch = () => {
const { wrapper: UrlState, onUrlUpdate } = createNuqsTestWrapper()
renderWithConsoleQuery(
<UrlState>
<EmbeddedMarketplaceSearch />
</UrlState>,
)
return { onUrlUpdate }
}
describe('EmbeddedMarketplaceSearch', () => {
beforeEach(() => {
deploymentState.deploymentEdition = 'CLOUD'
vi.clearAllMocks()
mockPluginSearch.mockResolvedValue({ data: { plugins: [plugin], total: 1 } })
mockTemplateSearch.mockResolvedValue({ data: { templates: [template], total: 1 } })
})
it('opens plugin and template details from the popup without filtering the catalog', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderSearch()
await user.type(screen.getByRole('combobox'), 'search')
await user.click(await screen.findByText('Google Search'))
await screen.findByRole('dialog', { name: /Google Search/ })
const pluginFrame = screen.getByTitle(/^Google Search/)
expect(new URL(pluginFrame.getAttribute('src')!).searchParams.get('installed')).toBe('false')
expect(onUrlUpdate).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.type(screen.getByRole('combobox'), 'legal')
await user.click(await screen.findByRole('option', { name: /Legal Research Agent/ }))
await screen.findByRole('dialog', { name: /Legal Research Agent/ })
const frame = screen.getByTitle(/^Legal Research Agent/) as HTMLIFrameElement
fireEvent(
window,
new MessageEvent('message', {
data: { type: 'dify-marketplace:install-template', templateId: 'template-1' },
origin: 'null',
source: frame.contentWindow,
}),
)
expect(push).toHaveBeenCalledExactlyOnceWith('/apps?template-id=template-1')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(onUrlUpdate).not.toHaveBeenCalled()
})
it('filters the current catalog when Enter is pressed instead of opening a result', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderSearch()
await user.type(screen.getByRole('combobox'), 'google')
await screen.findByRole('option', { name: /Google Search/ })
await user.keyboard('{Enter}')
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
})
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
afterEach(() => {
vi.restoreAllMocks()
})
it.each([
['COMMUNITY', 'plugin', 'mouse'],
['COMMUNITY', 'template', 'keyboard'],
['ENTERPRISE', 'plugin', 'keyboard'],
['ENTERPRISE', 'template', 'mouse'],
] as const)(
'opens a %s %s result using %s without changing the catalog',
async (edition, kind, interaction) => {
deploymentState.deploymentEdition = edition
const open = vi.spyOn(window, 'open').mockReturnValue(null)
mockPluginSearch.mockResolvedValue({
data: { plugins: kind === 'plugin' ? [plugin] : [], total: kind === 'plugin' ? 1 : 0 },
})
mockTemplateSearch.mockResolvedValue({
data: {
templates: kind === 'template' ? [template] : [],
total: kind === 'template' ? 1 : 0,
},
})
const user = userEvent.setup()
const { onUrlUpdate } = renderSearch()
const input = screen.getByRole('combobox')
await user.type(input, 'search')
const option = await screen.findByRole('option')
if (interaction === 'mouse') await user.click(option)
else await user.keyboard('{ArrowDown}{Enter}')
expect(open).toHaveBeenCalledTimes(1)
const [href, target, features] = open.mock.calls[0]!
const url = new URL(String(href))
expect(url.origin).toBe('https://marketplace.dify.ai')
expect(url.pathname).toBe(
kind === 'plugin' ? '/plugin/langgenius/google-search' : '/template/dify/template-1',
)
expect(url.searchParams.get('source')).toBe(window.location.origin)
expect(target).toBe('_blank')
expect(features).toBe('noopener,noreferrer')
expect(input).toHaveValue('search')
expect(onUrlUpdate).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
},
)
})
@@ -1,114 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vite-plus/test'
import HomeCatalogNavigation from '../home-catalog-navigation'
import HomeCatalogTabs from '../home-catalog-tabs'
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string, options?: { ns?: string }) =>
options?.ns ? `${options.ns}.${key}` : key,
),
}),
}
})
vi.mock('../../plugin-type-switch', () => ({
default: () => <div data-testid="plugin-type-switch" />,
}))
describe('HomeCatalogNavigation', () => {
const renderNavigation = (isMarketplacePlatform: boolean) => {
return render(
<HomeStickyStateProvider>
<HomeCatalogNavigation
isMarketplacePlatform={isMarketplacePlatform}
catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />}
/>
</HomeStickyStateProvider>,
)
}
it('keeps template navigation inside the Marketplace platform', () => {
renderNavigation(true)
const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
expect(activeTab).toHaveAttribute('aria-current', 'page')
expect(activeTab).toHaveAttribute('href', '/plugins')
expect(
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
).toHaveAttribute('href', '/templates')
})
it('leaves both catalog tabs inactive when no page is selected', () => {
render(<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />)
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
expect(pluginsTab).toHaveAttribute('href', '/marketplace')
expect(pluginsTab).not.toHaveAttribute('aria-current')
expect(templatesTab).not.toHaveAttribute('aria-current')
})
it('marks Templates as active when rendering the Templates catalog', () => {
render(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />)
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
expect(pluginsTab).not.toHaveAttribute('aria-current')
expect(templatesTab).toHaveAttribute('aria-current', 'page')
})
it('uses request-localized labels and preserves the selected language', () => {
render(
<HomeCatalogTabs
isMarketplacePlatform
labels={{
plugins: '插件',
templates: '模板',
}}
language="zh-Hans"
/>,
)
expect(screen.getByRole('link', { name: '插件' })).toHaveAttribute(
'href',
'/plugins?language=zh-Hans',
)
expect(screen.getByRole('link', { name: '模板' })).toHaveAttribute(
'href',
'/templates?language=zh-Hans',
)
})
it('renders a supplied catalog category navigation', () => {
render(
<HomeStickyStateProvider>
<HomeCatalogNavigation
isMarketplacePlatform
catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />}
catalogCategories={<nav aria-label="Template categories">Template categories</nav>}
/>
</HomeStickyStateProvider>,
)
expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument()
expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
})
it('keeps Dify catalog navigation on the current origin', () => {
renderNavigation(false)
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
'href',
'/marketplace',
)
expect(
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
).toHaveAttribute('href', '/templates')
})
})
@@ -0,0 +1,186 @@
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import HomeCatalogNavigation from '../home-catalog-navigation'
import HomeCatalogTabs from '../home-catalog-tabs'
import HomeHeader from '../home-header'
import HomeHero from '../home-hero'
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
const { boundaries, translations } = vi.hoisted(() => ({
boundaries: {
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
useDocLink: vi.fn(
() =>
(path = '') =>
`https://docs.dify.ai/console${path}`,
),
},
translations: {
'marketplace.home.creatorCenter': 'Creator Center',
'marketplace.home.guide': 'Guide',
pluginDevelopmentGuide: 'Plugin development guide',
pluginPublishGuide: 'Publish a plugin',
requestAPlugin: 'Request a plugin',
templatePublishingGuide: 'Publish a template',
'navigation.mainNav.marketplace': 'Marketplace navigation',
},
}))
vi.mock('#i18n', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...createReactI18nextMock(translations),
useLocale: () => 'en-US',
}
})
vi.mock('react-i18next', async (importOriginal) => {
const original = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return { ...original, ...createReactI18nextMock(translations) }
})
vi.mock('@/context/i18n', () => ({
defaultDocBaseUrl: 'https://docs.dify.ai',
useDocLink: boundaries.useDocLink,
}))
vi.mock('@/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/env')>()
return {
...actual,
env: {
...actual.env,
get NEXT_PUBLIC_MARKETPLACE_URL_PREFIX() {
return boundaries.marketplaceUrlPrefix
},
},
}
})
describe('Marketplace home chrome', () => {
beforeEach(async () => {
await page.viewport(1280, 800)
boundaries.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
vi.clearAllMocks()
})
it('keeps localized catalog navigation and the standalone guide keyboard reachable', async () => {
const screen = await render(
<TooltipProvider delay={0} closeDelay={0}>
<HomeStickyStateProvider>
<button type="button">Before header</button>
<HomeHeader
activeTab="templates"
catalogLabels={{ plugins: '插件', templates: '模板' }}
isMarketplacePlatform
language="zh-Hans"
/>
<HomeHero
isMarketplacePlatform
title="Discover templates"
subtitle="Start faster with ready-to-use workflows."
/>
<HomeCatalogNavigation
isMarketplacePlatform
catalogCategories={<nav aria-label="Template categories">Template categories</nav>}
catalogTabs={
<HomeCatalogTabs
activeTab="templates"
isMarketplacePlatform
labels={{ plugins: '插件', templates: '模板' }}
language="zh-Hans"
/>
}
/>
</HomeStickyStateProvider>
</TooltipProvider>,
)
await expect.element(screen.getByRole('heading', { name: 'Discover templates' })).toBeVisible()
await expect
.element(screen.getByText('Start faster with ready-to-use workflows.'))
.toBeVisible()
await expect
.element(screen.getByRole('navigation', { name: 'Template categories' }))
.toBeVisible()
const header = screen.getByRole('banner')
await expect
.element(header.getByRole('link', { name: 'Dify Marketplace' }))
.toHaveAttribute('href', '/')
const headerTemplate = header.getByRole('link', { name: '模板', includeHidden: true })
await expect.element(headerTemplate).toHaveAttribute('href', '/templates?language=zh-Hans')
await expect.element(headerTemplate).toHaveAttribute('aria-current', 'page')
await expect
.element(
screen.getByRole('navigation', { name: 'Marketplace navigation' }).getByRole('link', {
name: '模板',
}),
)
.toBeVisible()
await expect
.element(header.getByRole('link', { name: 'Creator Center' }))
.toHaveAttribute('href', 'https://creators.dify.ai/')
const before = screen.getByRole('button', { name: 'Before header' })
await before.click()
await userEvent.tab()
await expect.element(header.getByRole('link', { name: 'Dify Marketplace' })).toHaveFocus()
await userEvent.tab()
await expect.element(header.getByRole('link', { name: 'Creator Center' })).toHaveFocus()
await userEvent.tab()
const guide = header.getByRole('button', { name: 'Guide' })
await expect.element(guide).toHaveFocus()
await expect.element(screen.getByRole('tooltip')).toHaveTextContent('Guide')
await userEvent.keyboard('{Enter}')
const menu = screen.getByRole('menu')
await expect.element(menu).toBeVisible()
await expect
.element(menu.getByRole('menuitem', { name: 'Plugin development guide' }))
.toHaveAttribute(
'href',
'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin',
)
await expect.element(screen.getByRole('tooltip')).not.toBeInTheDocument()
})
it('keeps console navigation local and uses the deployment-aware guide after reopen', async () => {
const screen = await render(
<TooltipProvider delay={0} closeDelay={0}>
<HomeStickyStateProvider>
<HomeHeader activeTab={null} isMarketplacePlatform={false} />
<HomeCatalogNavigation
isMarketplacePlatform={false}
catalogCategories={<nav aria-label="Plugin categories">Plugin categories</nav>}
catalogTabs={<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />}
/>
</HomeStickyStateProvider>
</TooltipProvider>,
)
const header = screen.getByRole('banner')
await expect
.element(header.getByRole('link', { name: 'Dify Marketplace' }))
.toHaveAttribute('href', '/marketplace')
for (const link of header.getByRole('navigation').getByRole('link').all())
await expect.element(link).not.toHaveAttribute('aria-current')
const guide = header.getByRole('button', { name: 'Guide' })
await guide.click()
await expect
.element(screen.getByRole('menuitem', { name: 'Plugin development guide' }))
.toHaveAttribute(
'href',
'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin',
)
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByRole('menu')).not.toBeInTheDocument()
await expect.element(guide).toHaveFocus()
await guide.click()
await expect.element(screen.getByRole('menu')).toBeVisible()
expect(boundaries.useDocLink).toHaveBeenCalledOnce()
})
})
@@ -1,137 +0,0 @@
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import HomeGuide from '../home-guide'
const mocks = vi.hoisted(() => ({
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
useDocLink: vi.fn(() => (path?: string) => `https://docs.dify.ai/console${path || ''}`),
}))
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useLocale: () => 'en-US',
useTranslation: () => ({
i18n: {
language: 'en-US',
},
t: withSelectorKey((key: string) => key),
}),
}
})
vi.mock('@/context/i18n', () => ({
defaultDocBaseUrl: 'https://docs.dify.ai',
useDocLink: mocks.useDocLink,
}))
vi.mock('@/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/env')>()
return {
...actual,
env: {
...actual.env,
get NEXT_PUBLIC_MARKETPLACE_URL_PREFIX() {
return mocks.marketplaceUrlPrefix
},
},
}
})
const GUIDE_BUTTON_NAME = /marketplace\.home\.guide/
const renderGuide = (isMarketplacePlatform: boolean) =>
render(
<TooltipProvider delay={0} closeDelay={0}>
<HomeGuide isMarketplacePlatform={isMarketplacePlatform} />
</TooltipProvider>,
)
const openGuideMenu = async (isMarketplacePlatform: boolean) => {
const user = userEvent.setup()
renderGuide(isMarketplacePlatform)
expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: GUIDE_BUTTON_NAME }))
return within(await screen.findByRole('menu'))
}
describe('HomeGuide', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
})
it('opens a four-option dropdown on the standalone Marketplace instead of navigating away', async () => {
const menu = await openGuideMenu(true)
const options = menu.getAllByRole('menuitem')
expect(options).toHaveLength(4)
expect(options[0]).toHaveAttribute(
'href',
'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
)
expect(options[1]).toHaveAttribute(
'href',
'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin',
)
expect(options[2]).toHaveAttribute(
'href',
'https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-overview',
)
expect(options[3]).toHaveAttribute('href', 'https://creators.dify.ai/')
expect(mocks.useDocLink).not.toHaveBeenCalled()
})
it('uses Dify deployment-aware documentation links inside the console', async () => {
const menu = await openGuideMenu(false)
const options = menu.getAllByRole('menuitem')
expect(options).toHaveLength(4)
expect(options[1]).toHaveAttribute(
'href',
'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin',
)
expect(options[2]).toHaveAttribute(
'href',
'https://docs.dify.ai/console/develop-plugin/publishing/marketplace-listing/release-overview',
)
expect(mocks.useDocLink).toHaveBeenCalledOnce()
})
it('labels the in-app Guide icon and shows a matching tooltip on hover and focus', async () => {
const user = userEvent.setup()
renderGuide(false)
const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME })
expect(trigger).toHaveAccessibleName(/marketplace\.home\.guide/)
await user.hover(trigger)
expect((await screen.findByRole('tooltip')).textContent).toMatch(/marketplace\.home\.guide/)
await user.unhover(trigger)
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
await user.tab()
expect(trigger).toHaveFocus()
expect((await screen.findByRole('tooltip')).textContent).toMatch(/marketplace\.home\.guide/)
})
it('keeps the Guide dropdown available after the tooltip is shown', async () => {
const user = userEvent.setup()
renderGuide(false)
const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME })
await user.hover(trigger)
expect(await screen.findByRole('tooltip')).toBeInTheDocument()
await user.click(trigger)
expect(await screen.findByRole('menu')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
@@ -1,117 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import HomeHeader from '../home-header'
const mocks = vi.hoisted(() => ({
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
}))
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useLocale: () => 'en-US',
useTranslation: () => ({
i18n: {
language: 'en-US',
},
t: withSelectorKey((key: string) => key),
}),
}
})
vi.mock('@/context/i18n', () => ({
defaultDocBaseUrl: 'https://docs.dify.ai',
useDocLink: () => (path?: string) => `https://docs.dify.ai/console${path || ''}`,
}))
vi.mock('@/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/env')>()
return {
...actual,
env: {
...actual.env,
get NEXT_PUBLIC_MARKETPLACE_URL_PREFIX() {
return mocks.marketplaceUrlPrefix
},
},
}
})
vi.mock('../home-sticky-state-provider', () => ({
HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children,
}))
describe('HomeHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
})
it('keeps Creator Center and docs in the in-app header', () => {
render(<HomeHeader isMarketplacePlatform={false} />)
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument()
})
it('links Creator Center alongside the docs dropdown', () => {
render(<HomeHeader isMarketplacePlatform />)
const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })
expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument()
expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/')
expect(creatorCenterLink).toHaveAttribute('target', '_blank')
expect(creatorCenterLink).toHaveAttribute('rel', 'noopener noreferrer')
// Creator Center must be a single interactive element, not a link-wrapped button.
expect(creatorCenterLink.querySelector('button')).toBeNull()
expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
})
it('links Creator Center to the staging Creators site in staging', () => {
mocks.marketplaceUrlPrefix = 'https://marketplace-staging.dify.dev'
render(<HomeHeader isMarketplacePlatform />)
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
'href',
'https://creators-staging.dify.dev/',
)
})
it('selects neither catalog tab on non-catalog pages', () => {
render(
<HomeHeader
activeTab={null}
catalogLabels={{
plugins: 'Plugins',
templates: 'Templates',
}}
isMarketplacePlatform
/>,
)
expect(screen.getByRole('link', { name: 'Plugins' })).not.toHaveAttribute('aria-current')
expect(screen.getByRole('link', { name: 'Templates' })).not.toHaveAttribute('aria-current')
})
it('marks the active Templates catalog and preserves its language', () => {
render(
<HomeHeader
activeTab="templates"
catalogLabels={{
plugins: '插件',
templates: '模板',
}}
isMarketplacePlatform
language="zh-Hans"
/>,
)
expect(screen.getByRole('link', { name: '插件' })).not.toHaveAttribute('aria-current')
const templatesTab = screen.getByRole('link', { name: '模板' })
expect(templatesTab).toHaveAttribute('aria-current', 'page')
expect(templatesTab).toHaveAttribute('href', '/templates?language=zh-Hans')
})
})
@@ -1,28 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vite-plus/test'
import HomeHero from '../home-hero'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string) => key),
}),
}
})
describe('HomeHero', () => {
it('renders catalog-specific copy when supplied', () => {
render(
<HomeHero
isMarketplacePlatform
title="Discover templates"
subtitle="Start faster with ready-to-use workflows."
/>,
)
expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument()
expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument()
expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,469 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import { DirectionProvider } from '@langgenius/dify-ui/direction-provider'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { trackEvent } from '@/app/components/base/amplitude'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import HomeTrending from '../home-trending'
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('#i18n', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...createReactI18nextMock(),
useLocale: () => 'en-US',
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/service/plugins', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/plugins')>()),
fetchPluginInfoFromMarketPlace: vi
.fn<typeof import('@/service/plugins').fetchPluginInfoFromMarketPlace>()
.mockImplementation(async ({ org, name }) => ({
data: {
version: { version: '1.0.0' },
plugin: {
category: 'tool',
latest_package_identifier: `${org}/${name}:1.0.0`,
latest_version: '1.0.0',
},
},
})),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }),
}),
}
})
// Preserve real dialog and message handling without loading remote Marketplace documents.
vi.mock('@/features/marketplace/links', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/features/marketplace/links')>()
const toBlank = (url: string) =>
`about:blank${new URL(url, 'https://marketplace.example.test').search}`
return {
...actual,
marketplaceLinks: {
...actual.marketplaceLinks,
plugin: (...args: Parameters<typeof actual.marketplaceLinks.plugin>) =>
toBlank(actual.marketplaceLinks.plugin(...args)),
template: (...args: Parameters<typeof actual.marketplaceLinks.template>) =>
toBlank(actual.marketplaceLinks.template(...args)),
},
}
})
const banners: PluginBanner[] = [
{
id: 'recommend',
style_type: 'recommend',
title: 'Trending',
sort: 0,
language: 'en',
content: {
theme_type: 'hottest',
heading: 'Popular plugins',
description: 'Chosen from real usage.',
cards: [
{
item_type: 'plugin',
item_id: 'langgenius/dropbox',
display_name: 'Dropbox',
icon_url: '/api/v1/plugins/langgenius/dropbox/icon',
creator: 'langgenius',
badges: ['partner', 'verified'],
link: '/plugins/langgenius/dropbox',
card_position: 0,
auto_batch_id: '11111111-1111-4111-8111-111111111111',
},
{
item_type: 'plugin',
item_id: 'langgenius/zapier',
display_name: 'Zapier',
link: '/plugins/langgenius/zapier',
card_position: 1,
},
],
},
},
{
id: 'blog',
style_type: 'blog',
title: 'Dify Updates',
sort: 1,
language: 'en',
content: {
blog_title: 'Dify v1.9 new launch',
subtitle: 'New Agent node support',
description: 'Build agent workflows with the new Agent node.',
cover_image: '/api/v1/banners/images/banners/blog-cover.png',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
},
{
id: 'event',
style_type: 'event',
title: 'Duck Duck Go',
sort: 2,
language: 'en',
content: {
images: {
desktop: '/api/v1/banners/images/banners/duckduckgo.png',
mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png',
},
link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
alt_text: 'DuckDuckGo plugin',
},
},
]
const mixedRecommendations: PluginBanner = {
id: 'mixed',
style_type: 'recommend',
title: 'Trending',
sort: 0,
language: 'en',
content: {
theme_type: 'newest',
cards: [
{
item_type: 'plugin',
item_id: 'langgenius/dropbox',
display_name: 'Dropbox',
link: '',
card_position: 0,
},
{
item_type: 'template',
item_id: 'tpl-1',
display_name: 'Support Bot',
creator: 'aisa-team',
link: '',
card_position: 1,
},
],
},
}
async function renderTrending(
props: {
banners?: PluginBanner[]
direction?: 'ltr' | 'rtl'
edition?: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
isMarketplacePlatform?: boolean
} = {},
) {
const {
banners: items = banners,
direction = 'ltr',
edition = 'CLOUD',
isMarketplacePlatform = true,
} = props
const { queryClient, wrapper: ConsoleProvider } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: edition },
})
seedModelProviderQueries(queryClient)
return render(
<ConsoleProvider>
<button type="button">Before carousel</button>
<DirectionProvider direction={direction}>
<div dir={direction}>
<HomeTrending
banners={items}
isMarketplacePlatform={isMarketplacePlatform}
page="plugins"
/>
</div>
</DirectionProvider>
<button type="button">After carousel</button>
</ConsoleProvider>,
)
}
function expectSlideInsideCarousel(
screen: Awaited<ReturnType<typeof renderTrending>>,
name: string,
) {
return expect
.poll(() => {
const viewport = getCarousel(screen).element().getBoundingClientRect()
const slide = screen.getByRole('group', { name }).element().getBoundingClientRect()
return slide.left >= viewport.left - 1 && slide.right <= viewport.right + 1
})
.toBe(true)
}
function getCarousel(screen: Awaited<ReturnType<typeof renderTrending>>) {
const carousel = screen
.getByRole('region', { name: 'plugin.marketplace.home.trendingTitle' })
.all()
.find((locator) => locator.element().hasAttribute('data-home-trending-carousel-root'))
if (!carousel) throw new Error('Trending carousel was not rendered')
return carousel
}
describe('Marketplace home trending carousel', () => {
beforeEach(async () => {
await page.viewport(1200, 800)
onTestFinished(guardUnexpectedFetch())
document.documentElement.dataset.isMarketplace = ''
window.__marketplaceTracking__ = {
track: vi.fn(),
rememberReferrer: vi.fn(),
markSearch: vi.fn(),
flushSearch: vi.fn(),
markFilter: vi.fn(),
flushFilter: vi.fn(),
}
vi.clearAllMocks()
})
afterEach(() => {
delete window.__marketplaceTracking__
delete document.documentElement.dataset.isMarketplace
vi.restoreAllMocks()
})
it('switches among delivered layouts and keeps only the selected slide visible', async () => {
const screen = await renderTrending()
const trending = screen.getByRole('group', { name: 'Trending' })
const trendingElement = trending.element()
await expect.element(screen.getByRole('heading', { name: 'Popular plugins' })).toBeVisible()
await expect.element(trending.getByRole('link', { name: 'Dropbox' })).toBeVisible()
await expectSlideInsideCarousel(screen, 'Trending')
await screen.getByRole('button', { name: 'Dify Updates' }).click()
const blog = screen.getByRole('group', { name: 'Dify Updates' })
await expect.element(blog).toBeVisible()
await expect.element(blog.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeVisible()
await expectSlideInsideCarousel(screen, 'Dify Updates')
const viewport = getCarousel(screen).element().getBoundingClientRect()
const previousSlide = trendingElement.getBoundingClientRect()
expect(
previousSlide.right <= viewport.left + 1 || previousSlide.left >= viewport.right - 1,
).toBe(true)
await screen.getByRole('button', { name: 'Duck Duck Go' }).click()
await expect
.element(screen.getByRole('link', { name: 'DuckDuckGo plugin' }))
.toHaveAttribute('href', 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo')
await expectSlideInsideCarousel(screen, 'Duck Duck Go')
})
it('uses real hover and keyboard focus to pause announcements and lets Play resume in place', async () => {
const screen = await renderTrending()
const carousel = getCarousel(screen)
const track = carousel.element().querySelector('[aria-live]')!
expect(track).toHaveAttribute('aria-live', 'off')
await carousel.hover()
await expect.poll(() => track.getAttribute('aria-live')).toBe('polite')
await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).hover()
await expect.poll(() => track.getAttribute('aria-live')).toBe('polite')
await userEvent.unhover(carousel)
await expect.poll(() => track.getAttribute('aria-live')).toBe('off')
const second = screen.getByRole('button', { name: 'Dify Updates' })
await second.click()
await userEvent.unhover(carousel)
await userEvent.tab({ shift: true })
await expect.poll(() => track.getAttribute('aria-live')).toBe('polite')
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('button', { name: 'Trending' }))
.toHaveAttribute('aria-current', 'true')
const pause = screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' })
await pause.click()
const play = screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })
await expect.element(play).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
.toHaveFocus()
await expect.poll(() => track.getAttribute('aria-live')).toBe('off')
})
it('keeps reduced-motion autoplay stopped until the user explicitly starts it', async () => {
const nativeMatchMedia = window.matchMedia.bind(window)
vi.spyOn(window, 'matchMedia').mockImplementation((query) => {
const result = nativeMatchMedia(query)
if (query !== '(prefers-reduced-motion: reduce)') return result
return new Proxy(result, {
get(target, property) {
if (property === 'matches') return true
const value = Reflect.get(target, property)
return typeof value === 'function' ? value.bind(target) : value
},
})
})
const screen = await renderTrending()
const carousel = getCarousel(screen)
const track = carousel.element().querySelector('[aria-live]')!
const play = screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })
await expect.element(play).toBeVisible()
await play.click()
await userEvent.unhover(carousel)
await expect
.element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
.toBeVisible()
await expect.poll(() => track.getAttribute('aria-live')).toBe('off')
})
it('does not treat a real mouse drag on a narrow viewport as a touch swipe', async () => {
await page.viewport(390, 844)
const screen = await renderTrending()
const carousel = getCarousel(screen)
const card = screen.getByRole('link', { name: 'Dropbox' })
const cardClick = vi.fn((event: Event) => event.preventDefault())
card.element().addEventListener('click', cardClick)
await userEvent.pointer([
{ target: carousel, coords: { x: 300, y: 100 }, keys: '[MouseLeft>]' },
{ target: carousel, coords: { x: 80, y: 105 } },
'[/MouseLeft]',
])
await expect
.element(screen.getByRole('button', { name: 'Trending' }))
.toHaveAttribute('aria-current', 'true')
expect(cardClick).not.toHaveBeenCalled()
})
it('opens and closes embedded plugin and template details through their real cards', async () => {
const screen = await renderTrending({
banners: [mixedRecommendations],
isMarketplacePlatform: false,
})
await screen.getByRole('button', { name: 'Dropbox' }).click()
const pluginDialog = screen.getByRole('dialog', { name: /Dropbox/ })
await expect.element(pluginDialog).toBeVisible()
await pluginDialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(pluginDialog).not.toBeInTheDocument()
await screen.getByRole('button', { name: 'Support Bot' }).click()
const templateDialog = screen.getByRole('dialog', { name: /Support Bot/ })
await expect.element(templateDialog).toBeVisible()
await templateDialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(templateDialog).not.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Support Bot' })).toHaveFocus()
})
it.each([
['standalone', true, 'CLOUD'],
['community console', false, 'COMMUNITY'],
] as const)(
'keeps %s recommendations on the public navigation contract',
async (_, standalone, edition) => {
const screen = await renderTrending({
banners: [mixedRecommendations],
edition,
isMarketplacePlatform: standalone,
})
const plugin = screen.getByRole('link', { name: 'Dropbox' })
const template = screen.getByRole('link', { name: 'Support Bot' })
if (standalone) {
await expect.element(plugin).toHaveAttribute('href', '/plugin/langgenius/dropbox')
await expect.element(template).toHaveAttribute('href', '/template/aisa-team/tpl-1')
} else {
expect(new URL(plugin.element().getAttribute('href')!, window.location.origin).origin).toBe(
'https://marketplace.dify.ai',
)
expect(
new URL(template.element().getAttribute('href')!, window.location.origin).origin,
).toBe('https://marketplace.dify.ai')
await expect.element(plugin).toHaveAttribute('target', '_blank')
await expect.element(template).toHaveAttribute('target', '_blank')
}
},
)
it('clamps a selected slide after a delivered banner refresh without leaving blank space', async () => {
const screen = await renderTrending()
await screen.getByRole('button', { name: 'Duck Duck Go' }).click()
await expectSlideInsideCarousel(screen, 'Duck Duck Go')
await screen.rerender(
<DirectionProvider direction="ltr">
<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />
</DirectionProvider>,
)
await expect.element(screen.getByRole('group', { name: 'Trending' })).toBeVisible()
await expectSlideInsideCarousel(screen, 'Trending')
await expect
.element(screen.getByRole('button', { name: 'Duck Duck Go' }))
.not.toBeInTheDocument()
})
it('rejects unsafe CMS links and renders no carousel for an empty delivery', async () => {
const unsafe: PluginBanner = {
id: 'unsafe',
style_type: 'blog',
title: 'Unsafe Updates',
sort: 0,
language: 'en',
content: {
blog_title: 'Unsafe launch',
subtitle: 'Should not be clickable',
link: 'javascript:alert(1)',
link_target_type: 'blog',
},
}
const screen = await renderTrending({ banners: [unsafe] })
await expect.element(screen.getByRole('link')).not.toBeInTheDocument()
await expect
.element(screen.getByRole('heading', { name: 'Unsafe launch' }))
.not.toBeInTheDocument()
await screen.rerender(
<DirectionProvider direction="ltr">
<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />
</DirectionProvider>,
)
await expect
.element(screen.getByRole('region', { name: 'plugin.marketplace.home.trendingTitle' }))
.not.toBeInTheDocument()
})
it('tracks a recommendation only after its public link is activated', async () => {
const screen = await renderTrending({ banners: [banners[0]!] })
const link = screen.getByRole('link', { name: 'Dropbox' })
link.element().addEventListener('click', (event) => event.preventDefault())
await link.click()
expect(vi.mocked(trackEvent)).toHaveBeenCalledWith(
'marketplace_banner_item_click',
expect.objectContaining({
banner_id: 'recommend',
item_id: 'langgenius/dropbox',
item_type: 'plugin',
}),
)
expect(window.__marketplaceTracking__?.track).toHaveBeenCalledWith(
'marketplace_banner_click',
expect.objectContaining({ item_name: 'Dropbox', item_type: 'plugin' }),
)
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import MarketplaceLiveSearch from '../marketplace-live-search'
const router = vi.hoisted(() => ({
replace: vi.fn((href: string) => window.history.replaceState(null, '', href)),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => router,
}))
describe('Marketplace live search routing', () => {
beforeEach(async () => {
await page.viewport(1000, 700)
window.history.replaceState(null, '', '/plugins')
vi.clearAllMocks()
})
it('submits the trimmed query immediately and preserves the active catalog filters', async () => {
const screen = await render(
<MarketplaceLiveSearch
action="/templates/knowledge"
language="en-US"
placeholder="Search templates"
preserveParams={{ languages: ['ja', 'zh-Hans'], tags: ['agent'] }}
query=""
/>,
)
const search = screen.getByRole('searchbox', { name: 'Search templates' })
await search.fill(' legal research ')
await userEvent.keyboard('{Enter}')
await expect.poll(() => window.location.pathname).toBe('/templates/knowledge')
expect(Object.fromEntries(new URLSearchParams(window.location.search))).toEqual({
language: 'en-US',
languages: 'ja,zh-Hans',
q: 'legal research',
tags: 'agent',
})
await expect.element(search).toHaveValue(' legal research ')
expect(router.replace).toHaveBeenCalledOnce()
})
it('debounces typing and clears the query without leaving the active plugin tab', async () => {
const screen = await render(
<MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />,
)
const search = screen.getByRole('searchbox', { name: 'Search plugins' })
await search.fill('weather')
expect(window.location.pathname).toBe('/plugins')
await expect.poll(() => window.location.pathname).toBe('/plugins/tool')
expect(new URLSearchParams(window.location.search).get('q')).toBe('weather')
await search.clear()
await expect.poll(() => window.location.search).toBe('')
expect(window.location.pathname).toBe('/plugins/tool')
await expect.element(search).toHaveFocus()
})
})
@@ -1,74 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import MarketplaceLiveSearch from '../marketplace-live-search'
const { mockReplace } = vi.hoisted(() => ({
mockReplace: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
}))
describe('MarketplaceLiveSearch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updates the active tab result route while the user types', async () => {
const user = userEvent.setup()
render(
<MarketplaceLiveSearch
action="/templates/knowledge"
language="en-US"
placeholder="Search templates"
query=""
/>,
)
await user.type(screen.getByRole('searchbox'), 'legal')
await waitFor(() => {
expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', {
scroll: false,
})
})
})
it('clears the query without leaving the active plugin tab', async () => {
const user = userEvent.setup()
render(
<MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />,
)
await user.clear(screen.getByRole('searchbox'))
await waitFor(() => {
expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false })
})
})
it('preserves catalog filter params while the user types', async () => {
const user = userEvent.setup()
render(
<MarketplaceLiveSearch
action="/templates/knowledge"
placeholder="Search templates"
query=""
preserveParams={{ languages: ['ja'] }}
/>,
)
await user.type(screen.getByRole('searchbox'), 'legal')
await waitFor(() => {
expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', {
scroll: false,
})
})
})
})
@@ -14,14 +14,14 @@ import {
MarketplaceSearchForm,
} from '../marketplace-search-autocomplete'
const { mockTemplateSearch } = vi.hoisted(() => ({
mockTemplateSearch: vi.fn(),
const api = vi.hoisted(() => ({
searchPlugins: vi.fn(),
searchTemplates: vi.fn(),
}))
vi.mock('react-i18next', async (importOriginal) => {
const original = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...original,
...createReactI18nextMock({
@@ -38,30 +38,53 @@ vi.mock('react-i18next', async (importOriginal) => {
vi.mock('@/service/marketplace', async (importOriginal) => {
const original = await importOriginal<typeof import('@/service/marketplace')>()
return {
...original,
marketplaceQuery: {
...original.marketplaceQuery,
searchAdvanced: {
...original.marketplaceQuery.searchAdvanced,
queryOptions: (
options: Parameters<typeof original.marketplaceQuery.searchAdvanced.queryOptions>[0],
) => ({
...original.marketplaceQuery.searchAdvanced.queryOptions(options),
queryFn: () => ({ data: { plugins: [], total: 0 } }),
queryFn: () => api.searchPlugins(options.input),
}),
},
templateSearch: {
...original.marketplaceQuery.templateSearch,
queryOptions: (
options: Parameters<typeof original.marketplaceQuery.templateSearch.queryOptions>[0],
) => ({
...original.marketplaceQuery.templateSearch.queryOptions(options),
queryFn: () => mockTemplateSearch(options.input),
queryFn: () => api.searchTemplates(options.input),
}),
},
},
}
})
const plugin = {
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
}
const template = {
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
}
let queryClient: QueryClient
function Wrapper({ children }: { children: ReactNode }) {
@@ -70,7 +93,6 @@ function Wrapper({ children }: { children: ReactNode }) {
function StickyTemplateSearch() {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
@@ -84,7 +106,6 @@ function StickyTemplateSearch() {
function PinnedHeaderState() {
const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
return (
<header className="sticky top-0 z-50 flex h-12 items-center bg-background-default">
<span>Dify Marketplace</span>
@@ -97,16 +118,46 @@ function PinnedHeaderState() {
)
}
describe('Marketplace search autocomplete layout', () => {
beforeEach(() => {
function SelectionOwner() {
const [value, setValue] = useState('')
const [selection, setSelection] = useState('None')
const [submitted, setSubmitted] = useState(false)
return (
<form
onSubmit={(event) => {
event.preventDefault()
setSubmitted(true)
}}
>
<MarketplaceSearchAutocomplete
inputName="q"
locale="en-US"
onSuggestionSelect={(result) => {
setSelection(
result.kind === 'plugin' ? result.plugin.name : result.template.template_name,
)
}}
onValueChange={setValue}
placeholder="Search plugins or templates"
scope="all"
value={value}
/>
<output aria-label="Selected result">{selection}</output>
<output aria-label="Form submitted">{submitted ? 'yes' : 'no'}</output>
</form>
)
}
describe('Marketplace search autocomplete', () => {
beforeEach(async () => {
await page.viewport(1280, 720)
queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0, retry: false } } })
mockTemplateSearch.mockReset()
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
vi.clearAllMocks()
api.searchPlugins.mockResolvedValue({ data: { plugins: [], total: 0 } })
api.searchTemplates.mockResolvedValue({ data: { templates: [], total: 0 } })
})
it('keeps the pinned catalog layout stable while the results popup opens', async () => {
await page.viewport(1280, 720)
const screen = await render(
<Wrapper>
<HomeStickyStateProvider>
@@ -131,49 +182,38 @@ describe('Marketplace search autocomplete layout', () => {
</HomeStickyStateProvider>
</Wrapper>,
)
const container = screen.getByTestId('marketplace-scroll-container')
await expect.element(container).toBeVisible()
const scrollContainer = container.element()
scrollContainer.scrollTop = 300
scrollContainer.dispatchEvent(new Event('scroll'))
await new Promise(requestAnimationFrame)
const input = screen.getByRole('combobox', { name: 'Search templates' })
await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
const catalogNavigation = screen
const navigation = screen
.getByRole('region', { name: 'navigation.mainNav.marketplace' })
.element()
const scrollTopBefore = scrollContainer.scrollTop
await expect.element(input).toBeVisible()
const inputTopBefore = input.element().getBoundingClientRect().top
const navigationTopBefore = catalogNavigation.getBoundingClientRect().top
const scrollTop = scrollContainer.scrollTop
const inputTop = input.element().getBoundingClientRect().top
const navigationTop = navigation.getBoundingClientRect().top
await input.fill('open')
await expect.element(screen.getByText('No templates found')).toBeVisible()
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
// Base UI hides the page outside the open suggestions from assistive technology, so the
// pinned tabs are only reachable when hidden elements are included.
expect(scrollContainer.scrollTop).toBe(scrollTop)
expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTop)
expect(navigation.getBoundingClientRect().top).toBeCloseTo(navigationTop)
await expect
.element(screen.getByRole('tablist', { name: 'Header catalog tabs', includeHidden: true }))
.toBeVisible()
expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore)
})
// The search owns no Enter handler: the browser's implicit submission submits the route form
// when no suggestion is highlighted. Only a real browser performs that submission.
it('submits the route search form on Enter when no suggestion is highlighted', async () => {
const handleSubmit = vi.fn((event: Event) => {
event.preventDefault()
})
it('uses native implicit submission with the route form fields when no result is highlighted', async () => {
let submitted: Record<string, FormDataEntryValue> | undefined
const screen = await render(
<Wrapper>
<MarketplaceSearchForm
action="/search/templates"
language="en-US"
locale="en-US"
placeholder="Search templates"
query=""
@@ -181,16 +221,107 @@ describe('Marketplace search autocomplete layout', () => {
/>
</Wrapper>,
)
screen.container.querySelector('form')!.addEventListener('submit', handleSubmit)
let action: string | undefined
screen.container.addEventListener('submit', (event) => {
event.preventDefault()
const form = event.target
if (!(form instanceof HTMLFormElement)) return
action = form.action
submitted = Object.fromEntries(new FormData(form))
})
const input = screen.getByRole('combobox', { name: 'Search templates' })
await input.fill('open')
await expect.element(screen.getByText('No templates found')).toBeVisible()
await userEvent.keyboard('{Enter}')
expect(handleSubmit).toHaveBeenCalledOnce()
expect(action).toContain('/search/templates')
expect(submitted).toEqual({ language: 'en-US', q: 'open' })
await expect.element(input).toHaveValue('open')
})
it('groups mixed results and selects the highlighted option without submitting its owner', async () => {
api.searchPlugins.mockResolvedValue({ data: { plugins: [plugin], total: 1 } })
api.searchTemplates.mockResolvedValue({ data: { templates: [template], total: 1 } })
const screen = await render(
<Wrapper>
<SelectionOwner />
</Wrapper>,
)
const input = screen.getByRole('combobox', { name: 'Search plugins or templates' })
await input.fill('search')
await expect
.element(screen.getByRole('group', { name: 'Templates' }).getByText('Legal Research Agent'))
.toBeVisible()
await expect
.element(screen.getByRole('group', { name: 'Plugins' }).getByText('Google Search'))
.toBeVisible()
await userEvent.keyboard('{ArrowDown}')
await expect.element(input).toHaveAttribute('aria-activedescendant')
await userEvent.keyboard('{Enter}')
await expect
.element(screen.getByRole('status', { name: 'Selected result' }))
.toHaveTextContent('Legal Research Agent')
await expect
.element(screen.getByRole('status', { name: 'Form submitted' }))
.toHaveTextContent('no')
await expect.element(input).toHaveValue('')
})
it('keeps prior results during refresh but does not reopen after the user dismisses them', async () => {
let resolveRefresh!: (value: unknown) => void
api.searchPlugins.mockImplementation((input: { body: { query: string } }) => {
if (input.body.query === 'google')
return Promise.resolve({ data: { plugins: [plugin], total: 1 } })
return new Promise((resolve) => {
resolveRefresh = resolve
})
})
const screen = await render(
<Wrapper>
<SelectionOwner />
<button type="button">Outside search</button>
</Wrapper>,
)
const input = screen.getByRole('combobox', { name: 'Search plugins or templates' })
await input.fill('google')
await expect.element(screen.getByText('Google Search')).toBeVisible()
await input.fill('google drive')
await expect.element(screen.getByText('Google Search')).toBeVisible()
await expect.element(screen.getByText('Loading')).toBeVisible()
await expect.poll(() => typeof resolveRefresh).toBe('function')
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByRole('listbox')).not.toBeInTheDocument()
resolveRefresh({ data: { plugins: [plugin], total: 1 } })
await expect.element(screen.getByRole('listbox')).not.toBeInTheDocument()
})
it('announces an empty result and clears the controlled value without querying the other scope', async () => {
const screen = await render(
<Wrapper>
<MarketplaceSearchForm
action="/plugins"
locale="en-US"
placeholder="Search plugins"
query=""
scope="plugins"
/>
</Wrapper>,
)
const input = screen.getByRole('combobox', { name: 'Search plugins' })
await input.fill('zzzz')
await expect.element(screen.getByText('No integration found')).toBeVisible()
await expect.element(screen.getByText('Loading')).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await screen.getByRole('button', { name: 'Clear search' }).click()
await expect.element(input).toHaveValue('')
await expect.element(input).toHaveFocus()
expect(api.searchTemplates).not.toHaveBeenCalled()
})
})
@@ -1,580 +0,0 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { MARKETPLACE_API_PREFIX } from '@/features/marketplace/api'
import {
MarketplaceSearchAutocomplete,
MarketplaceSearchForm,
} from '../marketplace-search-autocomplete'
const { mockAssign, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
mockAssign: vi.fn(),
mockPluginSearch: vi.fn(),
mockTemplateSearch: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
clearSearch: 'Clear search',
loading: 'Loading',
'marketplace.loadError': 'Failed to load. Please try again.',
'marketplace.home.plugins': 'Plugins',
'marketplace.home.templates': 'Templates',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
})
})
vi.mock('@/service/marketplace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/marketplace')>()
return {
...actual,
marketplaceQuery: {
...actual.marketplaceQuery,
searchAdvanced: {
...actual.marketplaceQuery.searchAdvanced,
queryOptions: (
options: Parameters<typeof actual.marketplaceQuery.searchAdvanced.queryOptions>[0],
) => ({
...actual.marketplaceQuery.searchAdvanced.queryOptions(options),
queryFn: () => mockPluginSearch(options.input),
}),
},
templateSearch: {
...actual.marketplaceQuery.templateSearch,
queryOptions: (
options: Parameters<typeof actual.marketplaceQuery.templateSearch.queryOptions>[0],
) => ({
...actual.marketplaceQuery.templateSearch.queryOptions(options),
queryFn: () => mockTemplateSearch(options.input),
}),
},
},
}
})
let queryClient: QueryClient
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
describe('MarketplaceSearchAutocomplete', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssign.mockReset()
vi.spyOn(window.location, 'assign').mockImplementation(mockAssign)
queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 0,
retry: false,
},
},
})
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows template suggestions and keeps the route search form contract', async () => {
let resolveTemplateSearch!: (value: unknown) => void
const templateSearchPromise = new Promise((resolve) => {
resolveTemplateSearch = resolve
})
mockTemplateSearch.mockReturnValue(templateSearchPromise)
const templateSearchResponse = {
data: {
templates: [
{
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
},
],
total: 1,
},
}
const user = userEvent.setup()
const { container } = render(
<MarketplaceSearchForm
action="/templates/knowledge"
category="knowledge"
language="en-US"
locale="en-US"
placeholder="Search all templates..."
query=""
scope="templates"
/>,
{ wrapper: Wrapper },
)
await user.type(screen.getByRole('combobox'), 'legal')
expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument()
expect(screen.getByText(/Loading/)).toBeInTheDocument()
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
resolveTemplateSearch(templateSearchResponse)
expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument()
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument()
await user.click(screen.getByText('Legal Research Agent'))
expect(mockAssign).toHaveBeenCalledWith('/template/dify/template-1')
expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge')
expect(Object.fromEntries(new FormData(container.querySelector('form')!))).toEqual({
q: 'legal',
language: 'en-US',
})
await user.click(screen.getByRole('button', { name: 'Clear search' }))
expect(screen.getByRole('combobox')).toHaveValue('')
expect(mockPluginSearch).not.toHaveBeenCalled()
})
it('shows plugin suggestions while preserving the controlled search owner', async () => {
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const onValueChange = vi.fn()
const ControlledSearch = () => {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={(nextValue) => {
onValueChange(nextValue)
setValue(nextValue)
}}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
)
}
render(<ControlledSearch />, { wrapper: Wrapper })
await user.type(screen.getByRole('combobox'), 'google')
expect(await screen.findByText('Google Search')).toBeInTheDocument()
expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument()
expect(screen.getByRole('listbox').querySelector('img')).toHaveAttribute(
'src',
`${MARKETPLACE_API_PREFIX}/plugins/langgenius/google-search/icon`,
)
expect(onValueChange).toHaveBeenLastCalledWith('google')
expect(mockTemplateSearch).not.toHaveBeenCalled()
})
it('groups mixed suggestions and opens the selected result instead of viewing more', async () => {
mockTemplateSearch.mockResolvedValue({
data: {
templates: [
{
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
},
],
total: 1,
},
})
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const handleSubmit = vi.fn((event: Event) => {
event.preventDefault()
})
const { container } = render(
<MarketplaceSearchForm
action="/"
locale="en-US"
placeholder="Search plugins or templates"
query=""
scope="all"
/>,
{ wrapper: Wrapper },
)
container.querySelector('form')?.addEventListener('submit', handleSubmit)
await user.type(screen.getByRole('combobox'), 'search')
const templateGroup = await screen.findByRole('group', { name: 'Templates' })
const pluginGroup = screen.getByRole('group', { name: 'Plugins' })
expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument()
expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument()
await user.click(screen.getByText('Google Search'))
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
expect(handleSubmit).not.toHaveBeenCalled()
})
it('leaves Enter to the form submission when no suggestion is highlighted', async () => {
mockTemplateSearch.mockResolvedValue({
data: {
templates: [
{
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
},
],
total: 1,
},
})
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const handleSubmit = vi.fn((event: Event) => {
event.preventDefault()
})
const { container } = render(
<MarketplaceSearchForm
action="/search/all"
locale="en-US"
placeholder="Search plugins or templates"
query=""
scope="all"
/>,
{ wrapper: Wrapper },
)
container.querySelector('form')?.addEventListener('submit', handleSubmit)
await user.type(screen.getByRole('combobox'), 'search')
await screen.findByRole('option', { name: /Legal Research Agent/ })
// An IME confirmation keeps the suggestions open and submits nothing.
fireEvent.keyDown(screen.getByRole('combobox'), {
key: 'Enter',
keyCode: 229,
which: 229,
isComposing: true,
})
expect(handleSubmit).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Legal Research Agent/ })).toBeInTheDocument()
// Nothing is highlighted, so Enter is not consumed and the browser's implicit form submission
// runs; happy-dom has none, so the browser spec covers the submit.
expect(fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' })).toBe(true)
expect(screen.getByRole('combobox')).toHaveValue('search')
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
it('selects a suggestion without submitting when the parent handles the result', async () => {
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const onSuggestionSelect = vi.fn()
const handleSubmit = vi.fn((event: Event) => {
event.preventDefault()
})
const ControlledSearch = () => {
const [value, setValue] = useState('')
return (
<form>
<MarketplaceSearchAutocomplete
inputName="q"
locale="en-US"
onSuggestionSelect={onSuggestionSelect}
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
</form>
)
}
const { container } = render(<ControlledSearch />, { wrapper: Wrapper })
container.querySelector('form')?.addEventListener('submit', handleSubmit)
await user.type(screen.getByRole('combobox'), 'google')
await user.click(await screen.findByText('Google Search'))
expect(screen.getByRole('combobox')).toHaveValue('')
expect(mockAssign).not.toHaveBeenCalled()
expect(onSuggestionSelect).toHaveBeenCalledOnce()
expect(onSuggestionSelect.mock.calls[0]?.[0]).toMatchObject({
kind: 'plugin',
plugin: { name: 'google-search' },
})
expect(handleSubmit).not.toHaveBeenCalled()
})
it('keeps keyboard selection working for the highlighted suggestion', async () => {
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const handleSubmit = vi.fn((event: Event) => {
event.preventDefault()
})
const { container } = render(
<MarketplaceSearchForm
action="/plugins"
locale="en-US"
placeholder="Search plugins"
query=""
scope="plugins"
/>,
{ wrapper: Wrapper },
)
container.querySelector('form')?.addEventListener('submit', handleSubmit)
await user.type(screen.getByRole('combobox'), 'google')
expect(await screen.findByText('Google Search')).toBeInTheDocument()
await user.keyboard('{ArrowDown}{Enter}')
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
expect(handleSubmit).not.toHaveBeenCalled()
})
it('keeps the previous suggestions listed under a loading status while a new search is pending', async () => {
const googleResponse = {
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
}
mockPluginSearch.mockImplementation((input: { body: { query: string } }) => {
if (input.body.query === 'google') return Promise.resolve(googleResponse)
// Keep the follow-up term pending so the loading state stays observable.
return new Promise(() => {})
})
const user = userEvent.setup()
const ControlledSearch = () => {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
)
}
render(<ControlledSearch />, { wrapper: Wrapper })
await user.type(screen.getByRole('combobox'), 'google')
expect(await screen.findByText('Google Search')).toBeInTheDocument()
await user.type(screen.getByRole('combobox'), ' drive')
expect(screen.getByText('Google Search')).toBeInTheDocument()
expect(screen.getByText(/Loading/)).toBeInTheDocument()
await waitFor(() =>
expect(mockPluginSearch).toHaveBeenLastCalledWith(
expect.objectContaining({ body: expect.objectContaining({ query: 'google drive' }) }),
),
)
expect(screen.getByText('Google Search')).toBeInTheDocument()
expect(screen.getByText(/Loading/)).toBeInTheDocument()
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
})
it('does not reopen after dismiss while a request is still pending', async () => {
let resolvePluginSearch!: (value: unknown) => void
mockPluginSearch.mockReturnValue(
new Promise((resolve) => {
resolvePluginSearch = resolve
}),
)
const user = userEvent.setup()
const pluginResponse = {
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
}
const ControlledSearch = () => {
const [value, setValue] = useState('')
return (
<>
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
<button type="button">Outside search</button>
</>
)
}
render(<ControlledSearch />, { wrapper: Wrapper })
await user.type(screen.getByRole('combobox'), 'google')
expect(screen.getByText(/Loading/)).toBeInTheDocument()
// Base UI hides the rest of the page from assistive technology while the suggestions are
// open, so the outside control is reached by text rather than by role.
await user.click(screen.getByText('Outside search'))
await waitFor(() => {
const loading = screen.queryByText(/Loading/)
if (loading) expect(loading).not.toBeVisible()
})
await act(async () => resolvePluginSearch(pluginResponse))
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('announces an empty result after the search completes', async () => {
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
const user = userEvent.setup()
const ControlledSearch = () => {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
)
}
render(<ControlledSearch />, { wrapper: Wrapper })
await user.type(screen.getByRole('combobox'), 'zzzz')
expect(await screen.findByText('No integration found')).toBeInTheDocument()
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
})
})