diff --git a/web/app/(commonLayout)/plugins/page.spec.tsx b/web/app/(commonLayout)/plugins/page.spec.tsx index cd56e8680d3..e34c0550631 100644 --- a/web/app/(commonLayout)/plugins/page.spec.tsx +++ b/web/app/(commonLayout)/plugins/page.spec.tsx @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { MARKETPLACE_API_PREFIX } from '@/config' const mocks = vi.hoisted(() => ({ fetch: vi.fn(), @@ -41,6 +42,41 @@ describe('PluginList', () => { expect(mocks.redirect).toHaveBeenCalledWith( '/integrations/tools/built-in?package-ids=%5B%22langgenius%2Fexample-tool%22%5D', ) + expect(mocks.fetch).toHaveBeenCalledWith( + `${MARKETPLACE_API_PREFIX}/plugins/langgenius/example-tool`, + { cache: 'no-store' }, + ) + }) + + it('resolves a full package identifier through the identifier endpoint', async () => { + const packageId = 'langgenius/example-tool:1.0.0@checksum' + mocks.fetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: { + plugin: { + category: 'tool', + }, + }, + }), + }) + const { default: PluginList } = await import('./page') + + await expect( + PluginList({ + searchParams: Promise.resolve({ + 'package-ids': JSON.stringify([packageId]), + }), + }), + ).rejects.toThrow('NEXT_REDIRECT') + + expect(mocks.fetch).toHaveBeenCalledWith( + `${MARKETPLACE_API_PREFIX}/plugins/identifier?unique_identifier=${encodeURIComponent(packageId)}`, + { cache: 'no-store' }, + ) + expect(mocks.redirect).toHaveBeenCalledWith( + `/integrations/tools/built-in?package-ids=${encodeURIComponent(JSON.stringify([packageId]))}`, + ) }) it('redirects to integrations when category resolution fails', async () => { diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index f22d0e2a811..e26fc91ca28 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -4,6 +4,7 @@ import { getInstallRedirectPathByPluginCategory, getInstallRedirectPathFromSearchParams, getLegacyPluginRedirectPath, + parseMarketplacePluginId, shouldResolveInstallCategoryRedirect, } from '@/app/components/plugins/plugin-routes' import { MARKETPLACE_API_PREFIX } from '@/config' @@ -23,10 +24,11 @@ type MarketplaceManifestCategoryResponse = { const fetchPluginCategoryFromMarketplace = async (packageId: string) => { try { - const response = await fetch( - `${MARKETPLACE_API_PREFIX}/plugins/identifier?unique_identifier=${encodeURIComponent(packageId)}`, - { cache: 'no-store' }, - ) + const pluginId = parseMarketplacePluginId(packageId) + const path = pluginId + ? `/plugins/${encodeURIComponent(pluginId.org)}/${encodeURIComponent(pluginId.name)}` + : `/plugins/identifier?unique_identifier=${encodeURIComponent(packageId)}` + const response = await fetch(`${MARKETPLACE_API_PREFIX}${path}`, { cache: 'no-store' }) if (!response.ok) return undefined diff --git a/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx b/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx index 7bc623ce72a..edf6eb305e6 100644 --- a/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx +++ b/web/app/components/integrations/__tests__/plugin-category-page.spec.tsx @@ -7,6 +7,7 @@ import PluginCategoryPage from '../plugin-category-page' const { mockContainerRef, mockFetchManifestFromMarketPlace, + mockFetchPluginInfoFromMarketPlace, mockSetInstallState, mockUseUploader, mockUsePluginInstallation, @@ -14,6 +15,7 @@ const { } = vi.hoisted(() => ({ mockContainerRef: { current: null }, mockFetchManifestFromMarketPlace: vi.fn(), + mockFetchPluginInfoFromMarketPlace: vi.fn(), mockSetInstallState: vi.fn(), mockUseUploader: vi.fn((_: unknown) => ({ dragging: false, @@ -108,6 +110,8 @@ vi.mock('@/hooks/use-query-params', () => ({ vi.mock('@/service/plugins', () => ({ fetchManifestFromMarketPlace: (...args: unknown[]) => mockFetchManifestFromMarketPlace(...args), + fetchPluginInfoFromMarketPlace: (...args: unknown[]) => + mockFetchPluginInfoFromMarketPlace(...args), })) type UploaderOptions = { @@ -280,6 +284,49 @@ describe('PluginCategoryPage', () => { expect(mockSetInstallState).toHaveBeenCalledWith(null) }) + it('resolves a marketplace package name before opening the installer', async () => { + const packageId = 'langgenius/confluence_datasource' + const uniqueIdentifier = + 'langgenius/confluence_datasource:0.2.9@923a18de89d8cdb7f419d0dff60bf08a8b81b65fef6bf606cf0ce4b0ee56a9ca' + mockUsePluginInstallation.mockReturnValue([ + { packageId, bundleInfo: null }, + mockSetInstallState, + ]) + mockFetchPluginInfoFromMarketPlace.mockResolvedValue({ + data: { + plugin: { + latest_package_identifier: uniqueIdentifier, + }, + }, + }) + mockFetchManifestFromMarketPlace.mockResolvedValue({ + data: { + plugin: { + org: 'langgenius', + name: 'confluence_datasource', + category: PluginCategoryEnum.datasource, + }, + version: { version: '0.2.9' }, + }, + }) + + render() + + await waitFor(() => { + expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledWith({ + name: 'confluence_datasource', + org: 'langgenius', + }) + expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith( + encodeURIComponent(uniqueIdentifier), + ) + expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute( + 'data-unique-identifier', + uniqueIdentifier, + ) + }) + }) + it('ignores dropped files when install permission is unavailable', () => { render() diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts b/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts index 881f740eb9f..17d31b2cb2f 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts +++ b/web/app/components/plugins/install-plugin/hooks/use-install-from-marketplace-query.ts @@ -2,15 +2,29 @@ import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../../types' import { useEffect, useState } from 'react' +import { parseMarketplacePluginId } from '@/app/components/plugins/plugin-routes' import { MARKETPLACE_API_PREFIX } from '@/config' import { usePluginInstallation } from '@/hooks/use-query-params' -import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' +import { + fetchBundleInfoFromMarketPlace, + fetchManifestFromMarketPlace, + fetchPluginInfoFromMarketPlace, +} from '@/service/plugins' type MarketplaceInstall = { manifest: PluginDeclaration | PluginManifestInMarket + packageId: string uniqueIdentifier: string } +async function resolveMarketplaceUniqueIdentifier(packageId: string) { + const pluginId = parseMarketplacePluginId(packageId) + if (!pluginId) return packageId + + const { data } = await fetchPluginInfoFromMarketPlace(pluginId) + return data.plugin.latest_package_identifier +} + export type UseInstallFromMarketplaceQueryOptions = { canInstallPlugin: boolean isPermissionLoading?: boolean @@ -42,7 +56,8 @@ export const useInstallFromMarketplaceQuery = ({ const loadMarketplaceInstall = async () => { if (packageId) { try { - const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId)) + const uniqueIdentifier = await resolveMarketplaceUniqueIdentifier(packageId) + const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(uniqueIdentifier)) if (ignore) return const { plugin, version } = data @@ -50,7 +65,8 @@ export const useInstallFromMarketplaceQuery = ({ if (redirected) return setMarketplaceInstall({ - uniqueIdentifier: packageId, + packageId, + uniqueIdentifier, manifest: { ...plugin, version: version.version, @@ -103,7 +119,6 @@ export const useInstallFromMarketplaceQuery = ({ dependencies, hideInstallFromMarketplace, isShowInstallFromMarketplace, - marketplaceInstall: - marketplaceInstall?.uniqueIdentifier === packageId ? marketplaceInstall : null, + marketplaceInstall: marketplaceInstall?.packageId === packageId ? marketplaceInstall : null, } } diff --git a/web/app/components/plugins/plugin-routes.ts b/web/app/components/plugins/plugin-routes.ts index dcfd6614b2b..fa36fbba3ab 100644 --- a/web/app/components/plugins/plugin-routes.ts +++ b/web/app/components/plugins/plugin-routes.ts @@ -37,6 +37,13 @@ const getFirstSearchParamValue = (value: string | string[] | undefined) => { return value } +export const parseMarketplacePluginId = (packageId: string) => { + const [org, name, ...extraParts] = packageId.split('/') + if (!org || !name || extraParts.length || name.includes(':')) return null + + return { name, org } +} + const hasInstallSearchParams = (searchParams: LegacyPluginsSearchParams) => { return Object.keys(searchParams).some((key) => installSearchParamKeys.has(key)) }