From 30c106d9723dfd4c2e01c4bfe35268677ddfb53e Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 19:54:30 +0000 Subject: [PATCH 1/6] fix(website): slugify cloud-node pack ids and mock e2e snapshot The website's /cloud/supported-nodes routes use pack ids as URL segments, but pack ids come straight from upstream Python module names which mix PascalCase, snake_case, and kebab-case freely. After a Release: Website snapshot refresh that surfaced packs like ComfyUI-Crystools and basic_data_handling, the Playwright assertions in cloud-nodes.spec.ts broke because the slug regex requires kebab-case and the hardcoded comfyui-impact-pack assertions assumed registry contents would never change. Two coordinated fixes: * slugifyPackId in @comfyorg/object-info-parser normalizes every pack id (lowercase + _\u2192-) at the boundaries where it enters the website: groupNodesByPack for fresh fetches and readSnapshot for the bundled fallback. The raw upstream id is preserved as rawId/registryId so registry enrichment continues to hit https://api.comfy.org/nodes with the exact node_id the API expects. Packs whose raw ids collide on the same slug (24 such pairs exist in the live registry today, e.g. ComfyUI-QwenVL + ComfyUI_QwenVL) are merged deterministically instead of producing duplicate static routes. * WEBSITE_CLOUD_NODES_FIXTURE lets the e2e build read a committed fixture snapshot in place of the bundled one. CI sets it to e2e/fixtures/cloud-nodes.fixture.json so Playwright assertions run against deterministic pack content; future snapshot refreshes can no longer break the test contract by removing the Impact Pack or adding underscored ids. Verification: * 99/99 desktop Playwright tests pass against the fixture-built site * 81/81 website Vitest unit tests pass (including new build/fixture coverage) * 48/48 object-info-parser tests pass (11 new slugifyPackId cases, 4 new groupNodesByPack cases) * astro check: 0 errors --- .github/workflows/ci-website-e2e.yaml | 1 + .../e2e/fixtures/cloud-nodes.fixture.json | 156 ++++++++++++++++++ .../src/data/cloud-nodes.snapshot.json | 12 +- .../src/utils/cloudNodes.build.test.ts | 61 ++++++- apps/website/src/utils/cloudNodes.build.ts | 27 ++- apps/website/src/utils/cloudNodes.test.ts | 108 ++++++++++++ apps/website/src/utils/cloudNodes.ts | 26 ++- .../src/__tests__/groupNodesByPack.test.ts | 52 ++++++ .../src/__tests__/slugifyPackId.test.ts | 52 ++++++ .../src/helpers/groupNodesByPack.ts | 18 +- .../src/helpers/slugifyPackId.ts | 34 ++++ packages/object-info-parser/src/index.ts | 1 + 12 files changed, 528 insertions(+), 20 deletions(-) create mode 100644 apps/website/e2e/fixtures/cloud-nodes.fixture.json create mode 100644 packages/object-info-parser/src/__tests__/slugifyPackId.test.ts create mode 100644 packages/object-info-parser/src/helpers/slugifyPackId.ts diff --git a/.github/workflows/ci-website-e2e.yaml b/.github/workflows/ci-website-e2e.yaml index ea8e7f0592c..636e7598935 100644 --- a/.github/workflows/ci-website-e2e.yaml +++ b/.github/workflows/ci-website-e2e.yaml @@ -51,6 +51,7 @@ jobs: - name: Build website env: WEBSITE_GITHUB_STARS_OVERRIDE: 110000 + WEBSITE_CLOUD_NODES_FIXTURE: e2e/fixtures/cloud-nodes.fixture.json run: pnpm --filter @comfyorg/website build - name: Run Playwright tests diff --git a/apps/website/e2e/fixtures/cloud-nodes.fixture.json b/apps/website/e2e/fixtures/cloud-nodes.fixture.json new file mode 100644 index 00000000000..231ae804896 --- /dev/null +++ b/apps/website/e2e/fixtures/cloud-nodes.fixture.json @@ -0,0 +1,156 @@ +{ + "fetchedAt": "2026-01-01T00:00:00.000Z", + "packs": [ + { + "id": "comfyui-impact-pack", + "registryId": "comfyui-impact-pack", + "displayName": "ComfyUI Impact Pack", + "description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-banner.webp", + "iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-icon.webp", + "repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack", + "publisher": { + "id": "drltdata", + "name": "Dr.Lt.Data" + }, + "downloads": 2618646, + "githubStars": 3092, + "latestVersion": "8.28.3", + "license": "GPL-3.0", + "lastUpdated": "2026-04-19T17:08:04.993918Z", + "nodes": [ + { + "name": "FaceDetailer", + "displayName": "FaceDetailer", + "category": "ImpactPack/Detailer", + "description": "Detect and refine faces with iterative passes." + }, + { + "name": "DetailerForEach", + "displayName": "DetailerForEach", + "category": "ImpactPack/Detailer", + "description": "Run iterative detail refinement over detected SEG regions." + }, + { + "name": "UltralyticsDetectorProvider", + "displayName": "UltralyticsDetectorProvider", + "category": "ImpactPack/Detector", + "description": "Provide detector models powered by Ultralytics YOLO." + } + ] + }, + { + "id": "comfyui-crystools", + "registryId": "ComfyUI-Crystools", + "displayName": "ComfyUI-Crystools", + "description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-banner.webp", + "iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-icon.webp", + "repoUrl": "https://github.com/crystian/ComfyUI-Crystools", + "publisher": { + "id": "crystian", + "name": "Crystian" + }, + "downloads": 1671447, + "githubStars": 1855, + "latestVersion": "1.27.4", + "license": "MIT", + "lastUpdated": "2025-10-26T19:11:09.943366Z", + "nodes": [ + { + "name": "CCrystools_Show_Resources", + "displayName": "CCrystools_Show_Resources", + "category": "crystools/show", + "description": "Display GPU, RAM and disk usage live in the workflow." + }, + { + "name": "CCrystools_Show_Image", + "displayName": "CCrystools_Show_Image", + "category": "crystools/show", + "description": "Inspect images at full resolution with metadata overlays." + } + ] + }, + { + "id": "alpha-test-pack", + "registryId": "alpha-test-pack", + "displayName": "Alpha Test Pack", + "description": "Deterministic fixture pack used to anchor the A\u2192Z sort assertion in Playwright. Its display name starts with 'A' so it surfaces first when sorted alphabetically.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/alpha-test-pack-banner.webp", + "repoUrl": "https://github.com/Comfy-Org/alpha-test-pack", + "publisher": { + "id": "comfy-org", + "name": "Comfy Org" + }, + "downloads": 42, + "githubStars": 7, + "latestVersion": "0.1.0", + "license": "MIT", + "lastUpdated": "2026-01-01T00:00:00.000Z", + "nodes": [ + { + "name": "AlphaProbe", + "displayName": "AlphaProbe", + "category": "alpha/test", + "description": "Deterministic node used to verify alphabetical ordering." + } + ] + }, + { + "id": "rgthree-comfy", + "registryId": "rgthree-comfy", + "displayName": "rgthree-comfy", + "description": "Quality-of-life nodes from rgthree: Power Lora Loader, Display Any, Image Comparer, and more.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/rgthree-comfy-banner.webp", + "repoUrl": "https://github.com/rgthree/rgthree-comfy", + "publisher": { + "id": "rgthree", + "name": "rgthree" + }, + "downloads": 987654, + "githubStars": 1500, + "latestVersion": "2.0.0", + "license": "MIT", + "lastUpdated": "2026-03-15T00:00:00.000Z", + "nodes": [ + { + "name": "PowerLoraLoader", + "displayName": "Power Lora Loader", + "category": "rgthree/loaders", + "description": "Load multiple LoRAs at once with strength control." + }, + { + "name": "DisplayAny", + "displayName": "Display Any", + "category": "rgthree/display", + "description": "Display the value of any wire for debugging." + } + ] + }, + { + "id": "was-node-suite-comfyui", + "registryId": "was-node-suite-comfyui", + "displayName": "WAS Node Suite", + "description": "Large collection of utility nodes for image processing, text manipulation, and workflow control.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/was-node-suite-banner.webp", + "repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui", + "publisher": { + "id": "wasasquatch", + "name": "WASasquatch" + }, + "downloads": 1234567, + "githubStars": 1700, + "latestVersion": "1.0.0", + "license": "MIT", + "lastUpdated": "2026-02-01T00:00:00.000Z", + "nodes": [ + { + "name": "WASImageBlend", + "displayName": "Image Blend", + "category": "WAS/image", + "description": "Blend two images using a configurable mode." + } + ] + } + ] +} diff --git a/apps/website/src/data/cloud-nodes.snapshot.json b/apps/website/src/data/cloud-nodes.snapshot.json index a5a85565dbc..60842da2fff 100644 --- a/apps/website/src/data/cloud-nodes.snapshot.json +++ b/apps/website/src/data/cloud-nodes.snapshot.json @@ -50,7 +50,7 @@ ] }, { - "id": "ComfyUI-Crystools", + "id": "comfyui-crystools", "registryId": "ComfyUI-Crystools", "displayName": "ComfyUI-Crystools", "description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.", @@ -201,7 +201,7 @@ "id": "comfyui-easy-use", "registryId": "comfyui-easy-use", "displayName": "ComfyUI-Easy-Use", - "description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.", + "description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins \u2014 full loader, pre-sampling, easy KSampler, and XY plotting.", "iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg", "repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use", "publisher": { @@ -250,7 +250,7 @@ "id": "comfyui-advanced-controlnet", "registryId": "comfyui-advanced-controlnet", "displayName": "ComfyUI-Advanced-ControlNet", - "description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.", + "description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling \u2014 essential for animation and batched-latent workflows.", "repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet", "publisher": { "id": "kosinkadink", @@ -298,7 +298,7 @@ "id": "was-node-suite-comfyui", "registryId": "was-node-suite-comfyui", "displayName": "WAS Node Suite", - "description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.", + "description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O \u2014 the original \"kitchen sink\" pack still relied on by thousands of workflows.", "repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui", "publisher": { "id": "was", @@ -343,10 +343,10 @@ ] }, { - "id": "comfyui_ipadapter_plus", + "id": "comfyui-ipadapter-plus", "registryId": "comfyui_ipadapter_plus", "displayName": "ComfyUI_IPAdapter_plus", - "description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.", + "description": "Reference-image conditioning with IPAdapter \u2014 style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.", "repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus", "publisher": { "id": "matteo", diff --git a/apps/website/src/utils/cloudNodes.build.test.ts b/apps/website/src/utils/cloudNodes.build.test.ts index 0359987e192..a953cb4a2e6 100644 --- a/apps/website/src/utils/cloudNodes.build.test.ts +++ b/apps/website/src/utils/cloudNodes.build.test.ts @@ -4,7 +4,9 @@ import type { FetchOutcome } from './cloudNodes' import type { NodesSnapshot } from '../data/cloudNodes' const fetchCloudNodesMock = vi.hoisted(() => - vi.fn<() => Promise>() + vi.fn< + (options?: { snapshotUrl?: URL; apiKey?: string }) => Promise + >() ) const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn()) @@ -33,19 +35,26 @@ const SNAPSHOT: NodesSnapshot = { describe('loadPacksForBuild', () => { const savedVercelEnv = process.env.VERCEL_ENV + const savedFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE beforeEach(() => { fetchCloudNodesMock.mockReset() reportCloudNodesOutcomeMock.mockReset() delete process.env.VERCEL_ENV + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE }) afterEach(() => { if (savedVercelEnv === undefined) { delete process.env.VERCEL_ENV - return + } else { + process.env.VERCEL_ENV = savedVercelEnv + } + if (savedFixture === undefined) { + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE + } else { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedFixture } - process.env.VERCEL_ENV = savedVercelEnv }) it('returns packs when fetch is fresh', async () => { @@ -125,4 +134,50 @@ describe('loadPacksForBuild', () => { await expect(loadPacksForBuild()).rejects.toThrow() expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1) }) + + it('forwards WEBSITE_CLOUD_NODES_FIXTURE as snapshotUrl with an empty api key', async () => { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = + 'e2e/fixtures/cloud-nodes.fixture.json' + fetchCloudNodesMock.mockResolvedValue({ + status: 'stale', + snapshot: SNAPSHOT, + reason: 'missing WEBSITE_CLOUD_API_KEY' + }) + + await loadPacksForBuild() + const call = fetchCloudNodesMock.mock.calls[0]?.[0] + expect(call?.snapshotUrl).toBeInstanceOf(URL) + expect(call?.snapshotUrl?.protocol).toBe('file:') + expect(call?.snapshotUrl?.pathname).toMatch( + /apps\/website\/e2e\/fixtures\/cloud-nodes\.fixture\.json$/ + ) + expect(call?.apiKey).toBe('') + }) + + it('accepts an absolute path for WEBSITE_CLOUD_NODES_FIXTURE', async () => { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = '/etc/cloud-nodes.fixture.json' + fetchCloudNodesMock.mockResolvedValue({ + status: 'stale', + snapshot: SNAPSHOT, + reason: 'missing WEBSITE_CLOUD_API_KEY' + }) + + await loadPacksForBuild() + const call = fetchCloudNodesMock.mock.calls[0]?.[0] + expect(call?.snapshotUrl?.pathname).toBe('/etc/cloud-nodes.fixture.json') + }) + + it('does not throw on stale-in-production when the fixture override is set', async () => { + process.env.VERCEL_ENV = 'production' + process.env.WEBSITE_CLOUD_NODES_FIXTURE = + 'e2e/fixtures/cloud-nodes.fixture.json' + fetchCloudNodesMock.mockResolvedValue({ + status: 'stale', + snapshot: SNAPSHOT, + reason: 'missing WEBSITE_CLOUD_API_KEY' + }) + + const packs = await loadPacksForBuild() + expect(packs).toBe(SNAPSHOT.packs) + }) }) diff --git a/apps/website/src/utils/cloudNodes.build.ts b/apps/website/src/utils/cloudNodes.build.ts index 12ae56828d6..0c6fb5f5e65 100644 --- a/apps/website/src/utils/cloudNodes.build.ts +++ b/apps/website/src/utils/cloudNodes.build.ts @@ -1,3 +1,6 @@ +import { isAbsolute, resolve as resolvePath } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + import type { Pack } from '../data/cloudNodes' import { fetchCloudNodesForBuild } from './cloudNodes' @@ -7,10 +10,21 @@ const REFRESH_HINT = 'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' + 'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.' +const WEBSITE_PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url)) + function isProductionBuild(): boolean { return process.env.VERCEL_ENV === 'production' } +function fixtureSnapshotUrl(): URL | undefined { + const fixturePath = process.env.WEBSITE_CLOUD_NODES_FIXTURE + if (!fixturePath) return undefined + const absolute = isAbsolute(fixturePath) + ? fixturePath + : resolvePath(WEBSITE_PACKAGE_ROOT, fixturePath) + return pathToFileURL(absolute) +} + /** * Resolve the list of packs to render at build time. * @@ -23,9 +37,18 @@ function isProductionBuild(): boolean { * Production builds (VERCEL_ENV=production) fail hard on a stale outcome * to prevent silently shipping out-of-date snapshot data. Preview and * local builds continue to use the committed snapshot. + * + * Setting `WEBSITE_CLOUD_NODES_FIXTURE=` overrides the bundled + * snapshot with a fixture file on disk. This is used by the e2e build + * step in CI so Playwright assertions can be written against deterministic + * pack content instead of whatever the upstream registry happens to expose + * at the moment of the test run. The override never fires the live cloud + * API; the fixture path goes straight to the snapshot-fallback branch. */ export async function loadPacksForBuild(): Promise { - const outcome = await fetchCloudNodesForBuild() + const snapshotUrl = fixtureSnapshotUrl() + const options = snapshotUrl ? { snapshotUrl, apiKey: '' } : {} + const outcome = await fetchCloudNodesForBuild(options) reportCloudNodesOutcome(outcome) if (outcome.status === 'failed') { @@ -34,7 +57,7 @@ export async function loadPacksForBuild(): Promise { ) } - if (outcome.status === 'stale' && isProductionBuild()) { + if (outcome.status === 'stale' && isProductionBuild() && !snapshotUrl) { throw new Error( `Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` + `Reason: ${outcome.reason}. ${REFRESH_HINT}` diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 83b8939911d..9b96cc3dd95 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -306,4 +306,112 @@ describe('fetchCloudNodesForBuild', () => { }) 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('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 }) + }) }) diff --git a/apps/website/src/utils/cloudNodes.ts b/apps/website/src/utils/cloudNodes.ts index 2ad97a966e6..2947c2ae8d6 100644 --- a/apps/website/src/utils/cloudNodes.ts +++ b/apps/website/src/utils/cloudNodes.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { groupNodesByPack, sanitizeUserContent, + slugifyPackId, validateComfyNodeDef } from '@comfyorg/object-info-parser' @@ -240,7 +241,7 @@ async function parseCloudNodes( let registryMap = new Map() try { registryMap = await fetchRegistryPacks( - grouped.map((pack) => pack.id), + grouped.map((pack) => pack.rawId), { fetchImpl: options.fetchImpl } ) } catch { @@ -252,7 +253,7 @@ async function parseCloudNodes( pack.id, pack.displayName, pack.nodes, - registryMap.get(pack.id) + registryMap.get(pack.rawId) ) ) @@ -338,18 +339,35 @@ async function readSnapshot( snapshotUrl: URL | undefined ): Promise { if (!snapshotUrl) { - return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null + return isNodesSnapshot(bundledSnapshot) + ? normalizeSnapshotIds(bundledSnapshot) + : null } try { const text = await readFile(snapshotUrl, 'utf8') const parsed: unknown = JSON.parse(text) - if (isNodesSnapshot(parsed)) return parsed + if (isNodesSnapshot(parsed)) return normalizeSnapshotIds(parsed) return null } catch { return null } } +function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot { + const bySlug = new Map() + for (const pack of snapshot.packs) { + const slug = slugifyPackId(pack.id) + if (!slug) continue + const existing = bySlug.get(slug) + if (existing) { + existing.nodes = [...existing.nodes, ...pack.nodes] + continue + } + bySlug.set(slug, { ...pack, id: slug }) + } + return { ...snapshot, packs: [...bySlug.values()] } +} + function defaultSleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts index 6c490b1d1bc..fcf2b678b8c 100644 --- a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts +++ b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts @@ -51,4 +51,56 @@ describe('groupNodesByPack', () => { grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes ).toHaveLength(1) }) + + it('slugifies pack ids to lowercase, hyphen-only URL slugs', () => { + const grouped = groupNodesByPack({ + A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'), + B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes'), + C: makeNodeDef('C', 'custom_nodes.ComfyUI_yanc.nodes') + }) + + expect(grouped.map((pack) => pack.id)).toEqual([ + 'basic-data-handling', + 'comfyui-crystools', + 'comfyui-yanc' + ]) + }) + + it('preserves the raw upstream id for registry lookups', () => { + const grouped = groupNodesByPack({ + A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'), + B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes') + }) + + expect(grouped.find((pack) => pack.id === 'comfyui-crystools')?.rawId).toBe( + 'ComfyUI-Crystools' + ) + expect( + grouped.find((pack) => pack.id === 'basic-data-handling')?.rawId + ).toBe('basic_data_handling') + }) + + it('merges packs whose raw ids slugify to the same URL slug', () => { + const grouped = groupNodesByPack({ + QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'), + QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI_QwenVL.nodes') + }) + + expect(grouped).toHaveLength(1) + expect(grouped[0].id).toBe('comfyui-qwenvl') + expect(grouped[0].nodes.map((n) => n.className).sort()).toEqual([ + 'QwenA', + 'QwenB' + ]) + }) + + it('strips version suffix before slugifying', () => { + const grouped = groupNodesByPack({ + A: makeNodeDef('A', 'custom_nodes.ComfyUI_yanc@1_0_3.nodes') + }) + + expect(grouped).toHaveLength(1) + expect(grouped[0].id).toBe('comfyui-yanc') + expect(grouped[0].rawId).toBe('ComfyUI_yanc') + }) }) diff --git a/packages/object-info-parser/src/__tests__/slugifyPackId.test.ts b/packages/object-info-parser/src/__tests__/slugifyPackId.test.ts new file mode 100644 index 00000000000..6911ad6ce19 --- /dev/null +++ b/packages/object-info-parser/src/__tests__/slugifyPackId.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { slugifyPackId } from '../helpers/slugifyPackId' + +describe('slugifyPackId', () => { + it.for([ + ['comfyui-impact-pack', 'comfyui-impact-pack'], + ['ComfyUI-Crystools', 'comfyui-crystools'], + ['comfyui_impact_pack', 'comfyui-impact-pack'], + ['ComfyUI_QwenVL', 'comfyui-qwenvl'], + ['basic_data_handling', 'basic-data-handling'], + ['ComfyUI_Step1X-Edit', 'comfyui-step1x-edit'], + ['HunyuanVideo_Foley', 'hunyuanvideo-foley'] + ])('slugifies %s -> %s', ([input, expected]) => { + expect(slugifyPackId(input)).toBe(expected) + }) + + it('collapses runs of hyphens introduced by adjacent separators', () => { + expect(slugifyPackId('a__b')).toBe('a-b') + expect(slugifyPackId('a-_b')).toBe('a-b') + expect(slugifyPackId('a___-_b')).toBe('a-b') + }) + + it('strips leading and trailing separators', () => { + expect(slugifyPackId('_pack_')).toBe('pack') + expect(slugifyPackId('-pack-')).toBe('pack') + expect(slugifyPackId('__a__')).toBe('a') + }) + + it('produces URL-slug-safe output for every registry id observed today', () => { + const samples = [ + 'ComfyUI-AniPortrait', + 'comfyui_aniportrait', + 'ComfyUI-API-Manager', + 'ComfyUI_API_Manager', + 'comfy-oiio', + 'comfy_oiio', + 'ComfyUI-FlashVSR_Ultra_Fast', + 'comfyui-frame-interpolation', + 'Qwen3_TTS', + 'qwen3-tts' + ] + for (const sample of samples) { + expect(slugifyPackId(sample)).toMatch(/^[a-z0-9-]+$/) + } + }) + + it('returns the input unchanged when already a clean slug', () => { + expect(slugifyPackId('comfyui-impact-pack')).toBe('comfyui-impact-pack') + expect(slugifyPackId('rgthree-comfy')).toBe('rgthree-comfy') + }) +}) diff --git a/packages/object-info-parser/src/helpers/groupNodesByPack.ts b/packages/object-info-parser/src/helpers/groupNodesByPack.ts index 509205a800e..fbee6377556 100644 --- a/packages/object-info-parser/src/helpers/groupNodesByPack.ts +++ b/packages/object-info-parser/src/helpers/groupNodesByPack.ts @@ -1,5 +1,6 @@ import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource' import type { ComfyNodeDef } from '../schemas/nodeDefSchema' +import { slugifyPackId } from './slugifyPackId' export interface PackedNode { className: string @@ -8,6 +9,7 @@ export interface PackedNode { export interface NodePack { id: string + rawId: string displayName: string nodes: PackedNode[] } @@ -23,12 +25,17 @@ export function groupNodesByPack( continue } - const packId = def.python_module.split('.')[1]?.split('@')[0] - if (!packId) { + const rawId = def.python_module.split('.')[1]?.split('@')[0] + if (!rawId) { continue } - const existing = byPackId.get(packId) + const slug = slugifyPackId(rawId) + if (!slug) { + continue + } + + const existing = byPackId.get(slug) const node = { className, def } if (existing) { @@ -36,8 +43,9 @@ export function groupNodesByPack( continue } - byPackId.set(packId, { - id: packId, + byPackId.set(slug, { + id: slug, + rawId, displayName: source.displayText, nodes: [node] }) diff --git a/packages/object-info-parser/src/helpers/slugifyPackId.ts b/packages/object-info-parser/src/helpers/slugifyPackId.ts new file mode 100644 index 00000000000..598ce9f616a --- /dev/null +++ b/packages/object-info-parser/src/helpers/slugifyPackId.ts @@ -0,0 +1,34 @@ +/** + * Normalize a custom-node pack identifier into a URL-safe slug. + * + * Pack ids originate from Python module names exposed by ComfyUI and the + * Comfy custom-node registry. The upstream names mix three conventions + * freely: kebab-case (`comfyui-impact-pack`), snake_case + * (`comfyui_impact_pack`), and PascalCase (`ComfyUI-Crystools`). Using + * those raw strings as URL segments produces routes that are inconsistent + * across packs and fail the website's `[a-z0-9-]+` slug contract. + * + * `slugifyPackId` produces a deterministic, lowercase, hyphen-only slug + * suitable for use as a URL segment and as an `Astro.params` value. It + * does NOT replace the raw id used for registry lookups; callers that + * need to query the registry API must keep the raw `node_id` separately. + * + * The transformation is intentionally narrow: + * - lowercase + * - replace `_` with `-` + * - collapse runs of `-` to a single `-` + * - strip leading / trailing `-` + * + * Any other character (digits, letters, `-`) is preserved verbatim so + * legitimate registry ids like `comfyui-flashvsr-ultra-fast` survive + * untouched. The output is guaranteed to match `/^[a-z0-9-]+$/` as long + * as the input only contains ASCII letters, digits, `_`, and `-` — which + * is the case for every pack id observed in the registry today. + */ +export function slugifyPackId(rawId: string): string { + return rawId + .toLowerCase() + .replace(/_/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} diff --git a/packages/object-info-parser/src/index.ts b/packages/object-info-parser/src/index.ts index f512634cc92..b8ef87dbb70 100644 --- a/packages/object-info-parser/src/index.ts +++ b/packages/object-info-parser/src/index.ts @@ -2,3 +2,4 @@ export * from './schemas/nodeDefSchema' export * from './classifiers/nodeSource' export * from './helpers/groupNodesByPack' export * from './helpers/sanitizeUserContent' +export * from './helpers/slugifyPackId' From b58d24403bf6eae39a8e4faff81f5cda284e5df7 Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 20:07:27 +0000 Subject: [PATCH 2/6] fix(website): query every raw-id alias when pack slugs collide 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. --- apps/website/src/utils/cloudNodes.test.ts | 47 +++++++++++++++++++ apps/website/src/utils/cloudNodes.ts | 21 +++++++-- .../src/__tests__/groupNodesByPack.test.ts | 11 +++++ .../src/helpers/groupNodesByPack.ts | 5 ++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 9b96cc3dd95..01028cbe51b 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -345,6 +345,53 @@ describe('fetchCloudNodesForBuild', () => { ) }) + it('queries every raw-id alias when packs collide on the same slug and picks the first hit', async () => { + fetchRegistryPacksMock.mockResolvedValue( + new Map([ + ['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', diff --git a/apps/website/src/utils/cloudNodes.ts b/apps/website/src/utils/cloudNodes.ts index 2947c2ae8d6..2d059c4224d 100644 --- a/apps/website/src/utils/cloudNodes.ts +++ b/apps/website/src/utils/cloudNodes.ts @@ -238,12 +238,12 @@ async function parseCloudNodes( ) const grouped = groupNodesByPack(sanitizedDefs) + const allAliases = grouped.flatMap((pack) => pack.rawIds) let registryMap = new Map() try { - registryMap = await fetchRegistryPacks( - grouped.map((pack) => pack.rawId), - { fetchImpl: options.fetchImpl } - ) + registryMap = await fetchRegistryPacks(allAliases, { + fetchImpl: options.fetchImpl + }) } catch { registryMap = new Map() } @@ -253,13 +253,24 @@ async function parseCloudNodes( pack.id, pack.displayName, pack.nodes, - registryMap.get(pack.rawId) + pickRegistryPack(registryMap, pack.rawIds) ) ) return { kind: 'ok', packs, droppedNodes } } +function pickRegistryPack( + registryMap: Map, + aliases: readonly string[] +): RegistryPack | null | undefined { + for (const alias of aliases) { + const hit = registryMap.get(alias) + if (hit) return hit + } + return registryMap.get(aliases[0]) +} + function safeExternalUrl(value: string | undefined): string | undefined { if (!value) return undefined try { diff --git a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts index fcf2b678b8c..ac977f13a1a 100644 --- a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts +++ b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts @@ -92,6 +92,17 @@ describe('groupNodesByPack', () => { 'QwenA', 'QwenB' ]) + expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL', 'ComfyUI_QwenVL']) + }) + + it('does not record duplicate aliases when the same raw id appears twice', () => { + const grouped = groupNodesByPack({ + QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'), + QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI-QwenVL.nodes') + }) + + expect(grouped).toHaveLength(1) + expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL']) }) it('strips version suffix before slugifying', () => { diff --git a/packages/object-info-parser/src/helpers/groupNodesByPack.ts b/packages/object-info-parser/src/helpers/groupNodesByPack.ts index fbee6377556..b9073060a90 100644 --- a/packages/object-info-parser/src/helpers/groupNodesByPack.ts +++ b/packages/object-info-parser/src/helpers/groupNodesByPack.ts @@ -10,6 +10,7 @@ export interface PackedNode { export interface NodePack { id: string rawId: string + rawIds: string[] displayName: string nodes: PackedNode[] } @@ -40,12 +41,16 @@ export function groupNodesByPack( if (existing) { existing.nodes.push(node) + if (!existing.rawIds.includes(rawId)) { + existing.rawIds.push(rawId) + } continue } byPackId.set(slug, { id: slug, rawId, + rawIds: [rawId], displayName: source.displayText, nodes: [node] }) From df3c3c7efc5cc5abf8de07baa7b6d08870413ad2 Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 20:18:22 +0000 Subject: [PATCH 3/6] fix(website): preserve optional metadata when snapshot packs merge on slug Addresses CodeRabbit review: when normalizeSnapshotIds merged two snapshot packs whose ids slugified to the same value, only the nodes were combined and every other optional field (registryId, description, repoUrl, publisher, downloads, githubStars, license, ...) was silently dropped from the later alias. If the first row lacked metadata the second had, those fields were lost from the rendered detail page. mergeCollidedPacks now walks every key on the later pack and fills any undefined or null fields on the merged result, never overwriting metadata already present on the first row. The merge stays deterministic (first-wins for filled fields) and the rule covers the full Pack shape instead of an ad-hoc hand-listed subset. --- apps/website/src/utils/cloudNodes.test.ts | 78 +++++++++++++++++++++++ apps/website/src/utils/cloudNodes.ts | 14 +++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 01028cbe51b..9d31225e9f9 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -461,4 +461,82 @@ describe('fetchCloudNodesForBuild', () => { ]) rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) }) + + it('preserves optional metadata from later aliases when snapshot packs collide on slug', 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', + registryId: 'ComfyUI_QwenVL', + description: 'rich description from the underscore variant', + repoUrl: 'https://github.com/example/ComfyUI_QwenVL', + publisher: { id: 'qwen-team', name: 'Qwen Team' }, + downloads: 1234, + githubStars: 7, + license: 'MIT', + 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 + const merged = outcome.snapshot.packs[0] + expect(merged?.id).toBe('comfyui-qwenvl') + expect(merged?.registryId).toBe('ComfyUI_QwenVL') + expect(merged?.description).toBe( + 'rich description from the underscore variant' + ) + expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI_QwenVL') + expect(merged?.publisher).toEqual({ id: 'qwen-team', name: 'Qwen Team' }) + expect(merged?.downloads).toBe(1234) + expect(merged?.githubStars).toBe(7) + expect(merged?.license).toBe('MIT') + rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) + }) + + it('does not overwrite metadata already present on the first slug-collided pack', async () => { + const snapshotUrl = withSnapshotDir({ + fetchedAt: '2026-04-01T00:00:00.000Z', + packs: [ + { + id: 'ComfyUI-QwenVL', + displayName: 'first wins', + registryId: 'ComfyUI-QwenVL', + repoUrl: 'https://github.com/example/ComfyUI-QwenVL', + nodes: [{ name: 'A', displayName: 'A', category: 'x' }] + }, + { + id: 'ComfyUI_QwenVL', + displayName: 'second loses', + registryId: 'ComfyUI_QwenVL', + repoUrl: 'https://github.com/example/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 + const merged = outcome.snapshot.packs[0] + expect(merged?.displayName).toBe('first wins') + expect(merged?.registryId).toBe('ComfyUI-QwenVL') + expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI-QwenVL') + rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) + }) }) diff --git a/apps/website/src/utils/cloudNodes.ts b/apps/website/src/utils/cloudNodes.ts index 2d059c4224d..bb9cc6a7d62 100644 --- a/apps/website/src/utils/cloudNodes.ts +++ b/apps/website/src/utils/cloudNodes.ts @@ -371,7 +371,7 @@ function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot { if (!slug) continue const existing = bySlug.get(slug) if (existing) { - existing.nodes = [...existing.nodes, ...pack.nodes] + bySlug.set(slug, mergeCollidedPacks(existing, pack)) continue } bySlug.set(slug, { ...pack, id: slug }) @@ -379,6 +379,18 @@ function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot { return { ...snapshot, packs: [...bySlug.values()] } } +function mergeCollidedPacks(first: Pack, next: Pack): Pack { + const merged: Pack = { ...first, nodes: [...first.nodes, ...next.nodes] } + for (const [key, value] of Object.entries(next) as [keyof Pack, unknown][]) { + if (key === 'id' || key === 'nodes') continue + if (value === undefined || value === null) continue + if (merged[key] === undefined || merged[key] === null) { + ;(merged as Record)[key] = value + } + } + return merged +} + function defaultSleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } From 1c5a0079f041ce58fe6b5477152298782f14a040 Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 20:26:18 +0000 Subject: [PATCH 4/6] fix(website): fall back to raw upstream id when registry enrichment misses Addresses CodeRabbit review: registryId was sourced purely from registryPack?.id, so any pack whose registry lookup returned null (or whose entire fetchRegistryPacks call threw) ended up with registryId undefined. That breaks the new 'slugged id + raw registryId' contract and leaves downstream code with no stable upstream identifier. toDomainPack now takes a fallbackRegistryId derived from pack.rawIds[0] and uses it when registryPack is null/undefined, so every Pack always exposes the canonical raw alias even when the registry batch fails. Two regression tests pin the contract: * registry-miss path (fetchRegistryPacks returns empty map) * registry-throw path (fetchRegistryPacks rejects) --- apps/website/src/utils/cloudNodes.test.ts | 45 +++++++++++++++++++++++ apps/website/src/utils/cloudNodes.ts | 4 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 9d31225e9f9..27edebea494 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -307,6 +307,51 @@ describe('fetchCloudNodesForBuild', () => { expect(outcome.status).toBe('fresh') }) + it('falls back to the raw upstream id for registryId when registry lookup misses', async () => { + fetchRegistryPacksMock.mockResolvedValue(new Map()) + 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') + }) + + it('falls back to the raw upstream id for registryId when fetchRegistryPacks throws', async () => { + fetchRegistryPacksMock.mockImplementation(async () => { + throw new Error('registry unreachable') + }) + 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]?.registryId).toBe('ComfyUI_QwenVL') + }) + it('slugifies pack ids while querying the registry with the raw id', async () => { fetchRegistryPacksMock.mockResolvedValue( new Map([ diff --git a/apps/website/src/utils/cloudNodes.ts b/apps/website/src/utils/cloudNodes.ts index bb9cc6a7d62..60e0f606056 100644 --- a/apps/website/src/utils/cloudNodes.ts +++ b/apps/website/src/utils/cloudNodes.ts @@ -251,6 +251,7 @@ async function parseCloudNodes( const packs = grouped.map((pack) => toDomainPack( pack.id, + pack.rawIds[0], pack.displayName, pack.nodes, pickRegistryPack(registryMap, pack.rawIds) @@ -285,6 +286,7 @@ function safeExternalUrl(value: string | undefined): string | undefined { function toDomainPack( packId: string, + fallbackRegistryId: string | undefined, fallbackDisplayName: string, nodes: Array<{ className: string @@ -300,7 +302,7 @@ function toDomainPack( ): Pack { return { id: packId, - registryId: registryPack?.id, + registryId: registryPack?.id ?? fallbackRegistryId, displayName: registryPack?.name?.trim() || fallbackDisplayName || packId, description: registryPack?.description?.trim() || undefined, bannerUrl: safeExternalUrl(registryPack?.banner_url), From c753f30a5ff441ff2bba1f0fa12a5c05bb643920 Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 20:32:40 +0000 Subject: [PATCH 5/6] test(website): isolate cloudNodes suite from WEBSITE_CLOUD_NODES_FIXTURE Defensive hardening per CodeRabbit feedback. WEBSITE_CLOUD_NODES_FIXTURE is currently read only by loadPacksForBuild (cloudNodes.build.ts), not by fetchCloudNodesForBuild, so the tests in cloudNodes.test.ts are not affected today. But clearing the env var in beforeEach and restoring it in afterEach guards against future refactors that might let the override bleed into the fetcher, and matches the pattern already used in cloudNodes.build.test.ts. Also fixes a latent bug in the existing afterEach: previously, process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey would set the env var to the literal string 'undefined' when savedCloudApiKey was unset. Now both env vars are conditionally restored (matching the cloudNodes.build test convention). --- apps/website/src/utils/cloudNodes.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 27edebea494..90a9a949fde 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -87,6 +87,7 @@ function withSnapshotDir(snapshot: NodesSnapshot | null): URL { describe('fetchCloudNodesForBuild', () => { const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY + const savedCloudNodesFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE beforeEach(() => { resetCloudNodesFetcherForTests() @@ -94,11 +95,21 @@ describe('fetchCloudNodesForBuild', () => { fetchRegistryPacksMock.mockResolvedValue(new Map()) sanitizeCallSpy.mockReset() delete process.env.WEBSITE_CLOUD_API_KEY + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE }) afterEach(() => { vi.restoreAllMocks() - process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey + if (savedCloudApiKey === undefined) { + delete process.env.WEBSITE_CLOUD_API_KEY + } else { + process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey + } + if (savedCloudNodesFixture === undefined) { + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE + } else { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedCloudNodesFixture + } }) it('returns fresh when API succeeds', async () => { From 669bd5dbec571453f7917db6e22d7316ad3c13c8 Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 20:39:54 +0000 Subject: [PATCH 6/6] test(website): cover first-wins alias contract when both registry hits resolve CodeRabbit nitpick: the existing alias-collision test mocked ComfyUI-QwenVL -> null + ComfyUI_QwenVL -> hit, so the assertion that the first hit wins was actually proving 'the only non-null hit wins'. A regression that returns the last non-null entry would still pass. New test mocks BOTH aliases to non-null metadata with distinct registryId/repoUrl, and pins registryId === 'ComfyUI-QwenVL' (the first alias in pack.rawIds) so a 'last wins' or 'arbitrary wins' regression fails immediately. --- apps/website/src/utils/cloudNodes.test.ts | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 90a9a949fde..7db475eac09 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -448,6 +448,59 @@ describe('fetchCloudNodesForBuild', () => { ) }) + it('prefers the first non-null registry result when every alias resolves', async () => { + fetchRegistryPacksMock.mockResolvedValue( + new Map([ + [ + 'ComfyUI-QwenVL', + { + id: 'ComfyUI-QwenVL', + name: 'Dash Variant', + repository: 'https://github.com/example/dash-first' + } + ], + [ + 'ComfyUI_QwenVL', + { + id: 'ComfyUI_QwenVL', + name: 'Underscore Variant', + repository: 'https://github.com/example/underscore-second' + } + ] + ]) + ) + + 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]?.registryId).toBe('ComfyUI-QwenVL') + expect(outcome.snapshot.packs[0]?.repoUrl).toBe( + 'https://github.com/example/dash-first' + ) + 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',