mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
Addresses Oracle review feedback: when two raw upstream ids slugify to the same URL slug (e.g. ComfyUI-QwenVL + ComfyUI_QwenVL both -> comfyui-qwenvl) the previous merge kept only the first rawId and used only that single alias to fetch registry metadata. If that one alias missed but its twin would have resolved, the merged pack lost banner/icon/license info. Now NodePack carries rawIds: string[] holding every raw alias seen for the slug. parseCloudNodes flattens all aliases into a single registry batch and pickRegistryPack walks the alias list in insertion order to find the first non-null hit.
465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { NodesSnapshot } from '../data/cloudNodes'
|
|
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
|
|
|
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
|
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('./cloudNodes.registry', () => ({
|
|
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
|
fetchRegistryPacks: fetchRegistryPacksMock
|
|
}))
|
|
|
|
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as typeof ObjectInfoParser
|
|
return {
|
|
...actual,
|
|
sanitizeUserContent: (
|
|
defs: Parameters<typeof actual.sanitizeUserContent>[0]
|
|
) => {
|
|
sanitizeCallSpy(defs)
|
|
return actual.sanitizeUserContent(defs)
|
|
}
|
|
}
|
|
})
|
|
|
|
import {
|
|
fetchCloudNodesForBuild,
|
|
resetCloudNodesFetcherForTests
|
|
} from './cloudNodes'
|
|
|
|
const BASE_URL = 'https://cloud.test'
|
|
const KEY = 'cloud-secret'
|
|
|
|
function validNode(
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Record<string, unknown> {
|
|
return {
|
|
name: 'ImpactNode',
|
|
display_name: 'Impact Node',
|
|
description: 'Node description',
|
|
category: 'impact/testing',
|
|
output_node: false,
|
|
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
...init
|
|
})
|
|
}
|
|
|
|
function makeSnapshot(packCount = 1): NodesSnapshot {
|
|
const packs = Array.from({ length: packCount }, (_, i) => ({
|
|
id: `snapshot-pack-${i}`,
|
|
displayName: `Snapshot Pack ${i}`,
|
|
nodes: [
|
|
{
|
|
name: `SnapshotNode${i}`,
|
|
displayName: `Snapshot Node ${i}`,
|
|
category: 'snapshot'
|
|
}
|
|
]
|
|
}))
|
|
|
|
return {
|
|
fetchedAt: '2026-04-01T00:00:00.000Z',
|
|
packs
|
|
}
|
|
}
|
|
|
|
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
|
|
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
|
|
const file = join(dir, 'cloud-nodes.snapshot.json')
|
|
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
|
return pathToFileURL(file)
|
|
}
|
|
|
|
describe('fetchCloudNodesForBuild', () => {
|
|
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
|
|
|
|
beforeEach(() => {
|
|
resetCloudNodesFetcherForTests()
|
|
fetchRegistryPacksMock.mockReset()
|
|
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
|
sanitizeCallSpy.mockReset()
|
|
delete process.env.WEBSITE_CLOUD_API_KEY
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
|
})
|
|
|
|
it('returns fresh when API succeeds', async () => {
|
|
fetchRegistryPacksMock.mockResolvedValue(
|
|
new Map([
|
|
[
|
|
'comfyui-impact-pack',
|
|
{
|
|
id: 'comfyui-impact-pack',
|
|
name: 'ComfyUI Impact Pack',
|
|
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
|
}
|
|
]
|
|
])
|
|
)
|
|
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(outcome.status).toBe('fresh')
|
|
if (outcome.status !== 'fresh') return
|
|
expect(outcome.droppedCount).toBe(0)
|
|
expect(outcome.snapshot.packs).toHaveLength(1)
|
|
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
|
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
|
)
|
|
})
|
|
|
|
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () =>
|
|
response({
|
|
ValidNode: validNode({ name: 'ValidNode' }),
|
|
BrokenNode: {
|
|
name: 'BrokenNode',
|
|
python_module: 'custom_nodes.some-pack'
|
|
}
|
|
})
|
|
)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(outcome.status).toBe('fresh')
|
|
if (outcome.status !== 'fresh') return
|
|
expect(outcome.droppedCount).toBe(1)
|
|
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
|
|
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('applies sanitizer before grouping', async () => {
|
|
const fetchImpl = vi.fn(async () =>
|
|
response({
|
|
LoadImage: validNode({
|
|
name: 'LoadImage',
|
|
python_module: 'nodes',
|
|
input: {
|
|
required: {
|
|
image: [['private.png', 'public.webp'], {}]
|
|
}
|
|
}
|
|
}),
|
|
ImpactNode: validNode({
|
|
input: {
|
|
required: {
|
|
choice: [['safe', 'movie.mov'], {}]
|
|
}
|
|
}
|
|
})
|
|
})
|
|
)
|
|
|
|
await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('returns stale with missing env when snapshot is present', async () => {
|
|
const snapshot = makeSnapshot()
|
|
const snapshotUrl = withSnapshotDir(snapshot)
|
|
const fetchImpl = vi.fn()
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as unknown as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.reason).toMatch(/^missing /)
|
|
expect(fetchImpl).not.toHaveBeenCalled()
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('returns failed when env and snapshot are missing', async () => {
|
|
const snapshotUrl = withSnapshotDir(null)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
snapshotUrl,
|
|
fetchImpl: vi.fn() as unknown as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('failed')
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('does not retry on HTTP 401', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.reason).toMatch(/^HTTP 401/)
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('retries 5xx then falls back to snapshot', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
|
const sleep = vi.fn(async () => undefined)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
retryDelaysMs: [1, 1, 1],
|
|
sleep,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
|
expect(sleep).toHaveBeenCalledTimes(3)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('falls back to snapshot on envelope schema mismatch', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.reason).toMatch(/^envelope schema/)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('memoizes within a single process', async () => {
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
const opts = {
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
}
|
|
|
|
const [a, b] = await Promise.all([
|
|
fetchCloudNodesForBuild(opts),
|
|
fetchCloudNodesForBuild(opts)
|
|
])
|
|
|
|
expect(a).toBe(b)
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('throws when called twice with materially different options', async () => {
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(() =>
|
|
fetchCloudNodesForBuild({
|
|
apiKey: 'different-key',
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
).toThrow(/called twice with different options/)
|
|
})
|
|
|
|
it('returns fresh even when registry enrichment fails', async () => {
|
|
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('fresh')
|
|
})
|
|
|
|
it('slugifies pack ids while querying the registry with the raw id', async () => {
|
|
fetchRegistryPacksMock.mockResolvedValue(
|
|
new Map([
|
|
[
|
|
'ComfyUI_QwenVL',
|
|
{
|
|
id: 'ComfyUI_QwenVL',
|
|
name: 'ComfyUI QwenVL',
|
|
repository: 'https://github.com/example/ComfyUI_QwenVL'
|
|
}
|
|
]
|
|
])
|
|
)
|
|
|
|
const fetchImpl = vi.fn(async () =>
|
|
response({
|
|
QwenNode: validNode({
|
|
name: 'QwenNode',
|
|
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
|
})
|
|
})
|
|
)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(outcome.status).toBe('fresh')
|
|
if (outcome.status !== 'fresh') return
|
|
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
|
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
|
|
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
|
|
['ComfyUI_QwenVL'],
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('queries every raw-id alias when packs collide on the same slug and picks the first hit', async () => {
|
|
fetchRegistryPacksMock.mockResolvedValue(
|
|
new Map<string, unknown>([
|
|
['ComfyUI-QwenVL', null],
|
|
[
|
|
'ComfyUI_QwenVL',
|
|
{
|
|
id: 'ComfyUI_QwenVL',
|
|
name: 'ComfyUI QwenVL',
|
|
repository: 'https://github.com/example/ComfyUI_QwenVL'
|
|
}
|
|
]
|
|
])
|
|
)
|
|
|
|
const fetchImpl = vi.fn(async () =>
|
|
response({
|
|
QwenDash: validNode({
|
|
name: 'QwenDash',
|
|
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
|
|
}),
|
|
QwenUnder: validNode({
|
|
name: 'QwenUnder',
|
|
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
|
|
})
|
|
})
|
|
)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(outcome.status).toBe('fresh')
|
|
if (outcome.status !== 'fresh') return
|
|
expect(outcome.snapshot.packs).toHaveLength(1)
|
|
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
|
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
|
|
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
|
'https://github.com/example/ComfyUI_QwenVL'
|
|
)
|
|
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
|
|
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('normalizes pack ids when reading a fallback snapshot', async () => {
|
|
const snapshotUrl = withSnapshotDir({
|
|
fetchedAt: '2026-04-01T00:00:00.000Z',
|
|
packs: [
|
|
{
|
|
id: 'ComfyUI-Crystools',
|
|
displayName: 'ComfyUI-Crystools',
|
|
nodes: [
|
|
{
|
|
name: 'CrystoolsNode',
|
|
displayName: 'Crystools Node',
|
|
category: 'x'
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'basic_data_handling',
|
|
displayName: 'basic_data_handling',
|
|
nodes: [
|
|
{ name: 'BasicNode', displayName: 'Basic Node', category: 'x' }
|
|
]
|
|
}
|
|
]
|
|
})
|
|
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
snapshotUrl,
|
|
fetchImpl: vi.fn() as unknown as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.snapshot.packs.map((p) => p.id)).toEqual([
|
|
'comfyui-crystools',
|
|
'basic-data-handling'
|
|
])
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('merges packs in the fallback snapshot whose ids slugify to the same value', async () => {
|
|
const snapshotUrl = withSnapshotDir({
|
|
fetchedAt: '2026-04-01T00:00:00.000Z',
|
|
packs: [
|
|
{
|
|
id: 'ComfyUI-QwenVL',
|
|
displayName: 'ComfyUI QwenVL',
|
|
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
|
|
},
|
|
{
|
|
id: 'ComfyUI_QwenVL',
|
|
displayName: 'ComfyUI QwenVL',
|
|
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
|
|
}
|
|
]
|
|
})
|
|
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
snapshotUrl,
|
|
fetchImpl: vi.fn() as unknown as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.snapshot.packs).toHaveLength(1)
|
|
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
|
|
expect(outcome.snapshot.packs[0]?.nodes.map((n) => n.name).sort()).toEqual([
|
|
'A',
|
|
'B'
|
|
])
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
})
|