From 30c106d9723dfd4c2e01c4bfe35268677ddfb53e Mon Sep 17 00:00:00 2001 From: Glary-Bot Date: Thu, 14 May 2026 19:54:30 +0000 Subject: [PATCH] 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 ea8e7f0592..636e759893 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 0000000000..231ae80489 --- /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 a5a85565db..60842da2ff 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 0359987e19..a953cb4a2e 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 12ae56828d..0c6fb5f5e6 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 83b8939911..9b96cc3dd9 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 2ad97a966e..2947c2ae8d 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 6c490b1d1b..fcf2b678b8 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 0000000000..6911ad6ce1 --- /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 509205a800..fbee637755 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 0000000000..598ce9f616 --- /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 f512634cc9..b8ef87dbb7 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'