From af9011f2374ef366c7c8341eaae1b270ca93f192 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 2 Oct 2026 01:34:47 +0800 Subject: [PATCH] fix(web): load installed plugin icons through the authenticated route Multi-Workspace deployments fail the public plugin icon route closed (404), so plain icons broke everywhere locally and for the runner picker in production. A shared cached hook fetches the authenticated icon for installed plugins and leaves marketplace surfaces on the cloud URL. --- .../components/PluginProcessorSettings.tsx | 37 ++-- .../home/agents/components/RunnerSelect.tsx | 23 ++- .../dynamic-form/DynamicFormItemComponent.tsx | 19 +- .../pipeline-extensions/PipelineExtension.tsx | 43 +++-- .../ExtensionCardComponent.tsx | 14 +- .../PluginInstalledComponent.tsx | 4 +- .../plugin-card/PluginCardComponent.tsx | 28 ++- .../app/infra/hooks/useInstalledPluginIcon.ts | 118 ++++++++++++ web/src/app/wizard/page.tsx | 31 ++- .../hooks/useAuthenticatedPluginResource.ts | 50 ++--- .../unit/installed-plugin-icon-cache.test.mjs | 179 ++++++++++++++++++ 11 files changed, 434 insertions(+), 112 deletions(-) create mode 100644 web/src/app/infra/hooks/useInstalledPluginIcon.ts create mode 100644 web/tests/unit/installed-plugin-icon-cache.test.mjs diff --git a/web/src/app/home/agents/components/PluginProcessorSettings.tsx b/web/src/app/home/agents/components/PluginProcessorSettings.tsx index 34bc94ce8..d11fa822f 100644 --- a/web/src/app/home/agents/components/PluginProcessorSettings.tsx +++ b/web/src/app/home/agents/components/PluginProcessorSettings.tsx @@ -2,7 +2,7 @@ import { Puzzle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import type { RunnerDescriptor } from '@/app/infra/entities/api'; -import { httpClient } from '@/app/infra/http'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Select, @@ -27,6 +27,10 @@ function ProcessorComponentContent({ ...component.label, }); const pluginId = `${component.plugin_author}/${component.plugin_name}`; + const iconURL = useInstalledPluginIcon( + component.plugin_author, + component.plugin_name, + ); return ( - + {iconURL ? ( + + ) : ( + + )} {label} diff --git a/web/src/app/home/agents/components/RunnerSelect.tsx b/web/src/app/home/agents/components/RunnerSelect.tsx index cb706091c..08f6b06e7 100644 --- a/web/src/app/home/agents/components/RunnerSelect.tsx +++ b/web/src/app/home/agents/components/RunnerSelect.tsx @@ -3,7 +3,8 @@ import { Bot, ExternalLink, Loader2, Store } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { getCloudServiceClientSync, httpClient } from '@/app/infra/http'; +import { getCloudServiceClientSync } from '@/app/infra/http'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; import type { PluginV4 } from '@/app/infra/entities/plugin'; import { @@ -51,13 +52,13 @@ function installErrorMessage( return getErrorMessage(error) || t('wizard.aiEngine.installFailed'); } -function installedRunnerIconURL(option: IDynamicFormItemOption) { - return option.name.startsWith('plugin:') - ? (() => { - const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/); - return match ? httpClient.getPluginIconURL(match[1], match[2]) : null; - })() - : null; +function pluginOptionParts(option: IDynamicFormItemOption): { + author: string; + name: string; +} | null { + if (!option.name.startsWith('plugin:')) return null; + const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/); + return match ? { author: match[1], name: match[2] } : null; } function InstalledRunnerContent({ @@ -65,7 +66,8 @@ function InstalledRunnerContent({ }: { option: IDynamicFormItemOption; }) { - const iconURL = installedRunnerIconURL(option); + const parts = pluginOptionParts(option); + const iconURL = useInstalledPluginIcon(parts?.author, parts?.name); return ( @@ -90,7 +92,8 @@ function InstalledRunnerOptionContent({ option: IDynamicFormItemOption; description: string; }) { - const iconURL = installedRunnerIconURL(option); + const parts = pluginOptionParts(option); + const iconURL = useInstalledPluginIcon(parts?.author, parts?.name); return ( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index fd7811a02..9b15ea151 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -22,6 +22,7 @@ import { ControllerRenderProps } from 'react-hook-form'; import { Button } from '@/components/ui/button'; import { useEffect, useState } from 'react'; import { httpClient, systemInfo, userInfo } from '@/app/infra/http'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import { LLMModel, Bot, @@ -94,19 +95,6 @@ function hasUsableOptionName(option: { name?: string | null }): boolean { return typeof option.name === 'string' && option.name.trim().length > 0; } -function getPluginComponentIconURL(value?: string): string | null { - if (!value?.startsWith('plugin:')) { - return null; - } - - const match = value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/); - if (!match) { - return null; - } - - return httpClient.getPluginIconURL(match[1], match[2]); -} - function SelectOptionContent({ label, value, @@ -114,7 +102,10 @@ function SelectOptionContent({ label: string; value: string; }) { - const iconURL = getPluginComponentIconURL(value); + const match = value?.startsWith('plugin:') + ? value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/) + : null; + const iconURL = useInstalledPluginIcon(match?.[1], match?.[2]); return (
diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index a26779109..c6b98c027 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -12,7 +12,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; -import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react'; +import { CircleHelp, Plus, X, Server, Wrench, Sparkles, Puzzle } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; @@ -26,6 +26,27 @@ import { MCPServer, Skill } from '@/app/infra/entities/api'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice'; import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; + +function PluginThumbnail({ author, name }: { author: string; name: string }) { + const iconURL = useInstalledPluginIcon(author, name); + + if (!iconURL) { + return ( +
+ +
+ ); + } + + return ( + {name} + ); +} function InfoTooltip({ label }: { label: string }) { return ( @@ -373,13 +394,9 @@ export default function PipelineExtension({ className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent" >
- {metadata.name}
{metadata.name}
@@ -665,13 +682,9 @@ export default function PipelineExtension({ onClick={() => handleTogglePlugin(pluginId)} > - {metadata.name}
{metadata.name}
diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx index e3c030091..2add99f3e 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx @@ -13,7 +13,7 @@ import { Puzzle, } from 'lucide-react'; import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; -import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { @@ -39,10 +39,9 @@ export default function ExtensionCardComponent({ const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); const [iconFailed, setIconFailed] = useState(false); - const authenticatedIcon = useAuthenticatedPluginIcon( - cardVO.author, - cardVO.name, - cardVO.type === 'plugin', + const pluginIconURL = useInstalledPluginIcon( + cardVO.type === 'plugin' ? cardVO.author : null, + cardVO.type === 'plugin' ? cardVO.name : null, ); const FallbackIcon = @@ -51,9 +50,8 @@ export default function ExtensionCardComponent({ : cardVO.type === 'skill' ? Sparkles : Puzzle; - const iconSrc = - cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL; - const showFallback = iconFailed || authenticatedIcon.error || !iconSrc; + const iconSrc = cardVO.type === 'plugin' ? pluginIconURL : cardVO.iconURL; + const showFallback = iconFailed || !iconSrc; const getTypeLabel = (type: ExtensionType) => { switch (type) { diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 76e7ad956..be575d9b2 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -157,7 +157,9 @@ const PluginInstalledComponent = forwardRef< version: meta.version ?? '', enabled: plugin.enabled, type: marketplacePlugin?.type || 'plugin', - iconURL: httpClient.getPluginIconURL(author, name), + // Icon is resolved by ExtensionCardComponent through the shared + // installed-plugin hook (authenticated fetch in multi-Workspace + // deployments); the public /icon route 404s there. install_source: plugin.install_source, install_info: plugin.install_info, status: plugin.status, diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx index e5e8bf14c..f53b73db7 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx @@ -2,9 +2,16 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; -import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react'; +import { + BugIcon, + ExternalLink, + Ellipsis, + Trash, + ArrowUp, + Puzzle, +} from 'lucide-react'; import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; -import { httpClient } from '@/app/infra/http/HttpClient'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -27,6 +34,7 @@ export default function PluginCardComponent({ }) { const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); + const iconURL = useInstalledPluginIcon(cardVO.author, cardVO.name); return ( <> @@ -36,11 +44,17 @@ export default function PluginCardComponent({ >
{/* Icon - fixed width */} - plugin icon + {iconURL ? ( + plugin icon + ) : ( +
+ +
+ )} {/* Content area - flexible width with min-width to prevent overflow */}
diff --git a/web/src/app/infra/hooks/useInstalledPluginIcon.ts b/web/src/app/infra/hooks/useInstalledPluginIcon.ts new file mode 100644 index 000000000..0d26a38c6 --- /dev/null +++ b/web/src/app/infra/hooks/useInstalledPluginIcon.ts @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; + +import { httpClient } from '@/app/infra/http'; + +/** + * Resolve the icon of an *installed* plugin in every deployment. + * + * The Core's public `/icon` route only exists in single-workspace (OSS) + * installs; in multi-Workspace deployments it 404s, so installed-plugin + * surfaces must go through the authenticated `/authenticated-icon` route and + * stream the bytes as a blob object URL. + * + * - Core backend client: blob-fetch `getAuthenticatedPluginIconURL` and cache + * the resulting object URL per `author/name` (module-level, shared across + * every mounted consumer of the same icon). + * - Cloud/Space marketplace surfaces: callers that already resolved a + * marketplace URL pass it as `marketplaceURL`; it is returned verbatim and + * no blob fetch happens. + * - Returns `null` while loading / when the plugin has no icon, so callers + * keep rendering their existing fallback. + * + * Object URLs are revoked once the last subscriber unmounts (or the last + * subscriber's fetch settles with no remaining subscriber). + */ + +type CachedIcon = { url: string }; + +// Resolved object URLs, keyed by `author/name`. +const iconCache = new Map(); +// In-flight authenticated fetches, de-duplicated per key. +const inflightIcons = new Map>(); +// Mounted subscriber count per key, so we only revoke when nobody needs it. +const iconRefCounts = new Map(); + +function loadIcon( + key: string, + author: string, + name: string, +): Promise { + const existing = inflightIcons.get(key); + if (existing) return existing; + + const request = httpClient + .getAuthenticatedPluginIconURL(author, name) + .catch(() => null) + .finally(() => { + inflightIcons.delete(key); + }); + inflightIcons.set(key, request); + return request; +} + +export function useInstalledPluginIcon( + author?: string | null, + name?: string | null, + marketplaceURL?: string | null, +): string | null { + const key = author && name ? `${author}/${name}` : null; + const [url, setUrl] = useState(() => + marketplaceURL ?? (key ? iconCache.get(key)?.url ?? null : null), + ); + + useEffect(() => { + if (marketplaceURL) { + // Cloud/Space marketplace surface: already resolved upstream. + setUrl(marketplaceURL); + return; + } + if (!key || !author || !name) { + setUrl(null); + return; + } + + let active = true; + iconRefCounts.set(key, (iconRefCounts.get(key) ?? 0) + 1); + + const cached = iconCache.get(key); + if (cached) { + setUrl(cached.url); + } else { + setUrl(null); + void loadIcon(key, author, name).then((objectURL) => { + if (!objectURL) return; + // The last subscriber may have left while the fetch was in flight. + if (iconRefCounts.get(key) === undefined) { + URL.revokeObjectURL(objectURL); + return; + } + const current = iconCache.get(key); + if (current) { + // Another subscriber resolved first; share its object URL. + if (current.url !== objectURL) URL.revokeObjectURL(objectURL); + if (active) setUrl(current.url); + return; + } + iconCache.set(key, { url: objectURL }); + if (active) setUrl(objectURL); + }); + } + + return () => { + active = false; + const remaining = (iconRefCounts.get(key) ?? 1) - 1; + if (remaining > 0) { + iconRefCounts.set(key, remaining); + return; + } + iconRefCounts.delete(key); + const entry = iconCache.get(key); + if (entry) { + iconCache.delete(key); + URL.revokeObjectURL(entry.url); + } + }; + }, [key, author, name, marketplaceURL]); + + return marketplaceURL ?? url; +} diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index f2c83e1dd..6e0fb054c 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -29,6 +29,7 @@ import { } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; import { systemInfo, bootstrapWorkspaceSession, @@ -1904,6 +1905,30 @@ function runnerPluginId(runner: string) { .join('/'); } +function InstalledRunnerIcon({ + author, + name, +}: { + author: string; + name: string; +}) { + const iconURL = useInstalledPluginIcon(author, name); + + if (!iconURL) { + return ( + + ); + } + + return ( + + ); +} + function StepAIEngine({ choice, onChoiceChange, @@ -2298,11 +2323,7 @@ function StepAIEngine({ > {author && name ? ( - + ) : ( )} diff --git a/web/src/hooks/useAuthenticatedPluginResource.ts b/web/src/hooks/useAuthenticatedPluginResource.ts index c271d99ff..3b534df2e 100644 --- a/web/src/hooks/useAuthenticatedPluginResource.ts +++ b/web/src/hooks/useAuthenticatedPluginResource.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useCurrentWorkspace } from '@/app/infra/http'; +import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; type AuthenticatedResourceState = { key: string; @@ -14,50 +15,21 @@ const EMPTY_RESOURCE: AuthenticatedResourceState = { error: false, }; +/** + * Backwards-compatible wrapper around the shared installed-plugin icon hook. + * Kept for callers that want a `{ url, error }` shape; the url is '' while + * loading or when the icon is unavailable. + */ export function useAuthenticatedPluginIcon( author: string, name: string, enabled = true, ): { url: string; error: boolean } { - const [resource, setResource] = - useState(EMPTY_RESOURCE); - const currentWorkspace = useCurrentWorkspace(); - const workspaceUuid = currentWorkspace?.workspace.uuid; - const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`; - - useEffect(() => { - if (!enabled) { - setResource({ key: resourceKey, url: '', error: false }); - return; - } - let active = true; - let objectURL = ''; - setResource({ key: resourceKey, url: '', error: false }); - httpClient - .getAuthenticatedPluginIconURL(author, name) - .then((nextURL) => { - objectURL = nextURL; - if (active) { - setResource({ key: resourceKey, url: nextURL, error: false }); - } else { - URL.revokeObjectURL(nextURL); - } - }) - .catch(() => { - if (active) { - setResource({ key: resourceKey, url: '', error: true }); - } - }); - return () => { - active = false; - if (objectURL) URL.revokeObjectURL(objectURL); - }; - }, [author, enabled, name, resourceKey]); - - return { - url: resource.key === resourceKey ? resource.url : '', - error: resource.key === resourceKey && resource.error, - }; + const url = useInstalledPluginIcon( + enabled ? author : null, + enabled ? name : null, + ); + return { url: url ?? '', error: false }; } export function useAuthenticatedPluginAsset( diff --git a/web/tests/unit/installed-plugin-icon-cache.test.mjs b/web/tests/unit/installed-plugin-icon-cache.test.mjs new file mode 100644 index 000000000..81e72046e --- /dev/null +++ b/web/tests/unit/installed-plugin-icon-cache.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/infra/hooks/useInstalledPluginIcon.ts', +); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; + +// --- minimal React + backend mocks so the hook can run under node:test --- +let stateSlots = []; +let lastCleanup; + +const reactMock = { + useState(initial) { + const value = typeof initial === 'function' ? initial() : initial; + const index = stateSlots.length; + stateSlots.push(value); + return [ + value, + (next) => { + stateSlots[index] = + typeof next === 'function' ? next(stateSlots[index]) : next; + }, + ]; + }, + useEffect(effect) { + const cleanup = effect(); + if (typeof cleanup === 'function') lastCleanup = cleanup; + }, +}; + +const httpMock = { + httpClient: { + getAuthenticatedPluginIconURL: () => { + throw new Error('fetch not configured for this test'); + }, + }, +}; + +const sourceRequire = createRequire(sourcePath); + +function loadHook() { + const loadedModule = { exports: {} }; + const requireShim = (id) => { + if (id === 'react') return reactMock; + if (id === '@/app/infra/http') return httpMock; + return sourceRequire(id); + }; + new Function('require', 'module', 'exports', compiled)( + requireShim, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports.useInstalledPluginIcon; +} + +const revoked = []; +globalThis.URL.revokeObjectURL = (url) => revoked.push(url); + +function reset() { + stateSlots = []; + lastCleanup = undefined; + revoked.length = 0; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('caches per author/name and revokes once the last subscriber leaves', async () => { + let fetches = 0; + let resolveFetch; + httpMock.httpClient.getAuthenticatedPluginIconURL = () => { + fetches += 1; + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }; + reset(); + const useInstalledPluginIcon = loadHook(); + + const first = useInstalledPluginIcon('acme', 'demo'); + const firstCleanup = lastCleanup; + assert.equal(first, null); + assert.equal(fetches, 1); + + resolveFetch('blob:acme/demo'); + await flush(); + + // Second subscriber reuses the cached object URL synchronously. + const second = useInstalledPluginIcon('acme', 'demo'); + const secondCleanup = lastCleanup; + assert.equal(second, 'blob:acme/demo'); + assert.equal(fetches, 1); + + firstCleanup(); + assert.deepEqual(revoked, []); + secondCleanup(); + assert.deepEqual(revoked, ['blob:acme/demo']); + + // Cache was dropped with the last subscriber, so a new mount refetches. + useInstalledPluginIcon('acme', 'demo'); + assert.equal(fetches, 2); +}); + +test('de-duplicates concurrent subscribers and revokes a shared icon once', async () => { + let fetches = 0; + let resolveFetch; + httpMock.httpClient.getAuthenticatedPluginIconURL = () => { + fetches += 1; + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }; + reset(); + const useInstalledPluginIcon = loadHook(); + + useInstalledPluginIcon('acme', 'demo'); + const firstCleanup = lastCleanup; + useInstalledPluginIcon('acme', 'demo'); + const secondCleanup = lastCleanup; + assert.equal(fetches, 1); + + resolveFetch('blob:shared'); + await flush(); + + firstCleanup(); + assert.deepEqual(revoked, []); + secondCleanup(); + assert.deepEqual(revoked, ['blob:shared']); +}); + +test('caches nothing and revokes nothing when the authenticated fetch fails', async () => { + let fetches = 0; + httpMock.httpClient.getAuthenticatedPluginIconURL = () => { + fetches += 1; + return Promise.reject(new Error('not found')); + }; + reset(); + const useInstalledPluginIcon = loadHook(); + + const value = useInstalledPluginIcon('acme', 'missing'); + const cleanup = lastCleanup; + assert.equal(value, null); + + await flush(); + assert.equal(useInstalledPluginIcon('acme', 'missing'), null); + assert.equal(fetches, 2); // failures are not negatively cached + + cleanup(); + assert.deepEqual(revoked, []); +}); + +test('returns a marketplace URL verbatim without fetching', () => { + let fetches = 0; + httpMock.httpClient.getAuthenticatedPluginIconURL = () => { + fetches += 1; + return Promise.resolve('blob:unused'); + }; + reset(); + const useInstalledPluginIcon = loadHook(); + + const value = useInstalledPluginIcon( + 'acme', + 'demo', + 'https://space.langbot.app/icon', + ); + assert.equal(value, 'https://space.langbot.app/icon'); + assert.equal(fetches, 0); + assert.equal(lastCleanup, undefined); +});