mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
Compare commits
3 Commits
coderabbit
...
glary/rewa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a1c0de7ff | ||
|
|
7160a9ee3f | ||
|
|
71092b2011 |
1
.github/workflows/ci-website-e2e.yaml
vendored
1
.github/workflows/ci-website-e2e.yaml
vendored
@@ -51,7 +51,6 @@ 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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
class="border-y border-solid border-neutral-700"
|
||||
:class="{
|
||||
'opacity-50': runner.resolved,
|
||||
'opacity-75': isLoading && runner.resolved
|
||||
}"
|
||||
>
|
||||
<td class="text-center w-16">
|
||||
<td class="w-16 text-center">
|
||||
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<Button
|
||||
class="inline-block mx-2"
|
||||
class="mx-2 inline-block"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
@@ -22,11 +22,11 @@
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="text-right px-4">
|
||||
<td class="px-4 text-right">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 \u2014 full loader, pre-sampling, easy KSampler, and XY plotting.",
|
||||
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — 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 \u2014 essential for animation and batched-latent workflows.",
|
||||
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — 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 \u2014 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 — 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 \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.",
|
||||
"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.",
|
||||
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
|
||||
"publisher": {
|
||||
"id": "matteo",
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
const fetchCloudNodesMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(options?: { snapshotUrl?: URL; apiKey?: string }) => Promise<FetchOutcome>
|
||||
>()
|
||||
vi.fn<() => Promise<FetchOutcome>>()
|
||||
)
|
||||
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -35,26 +33,19 @@ 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
|
||||
} else {
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
}
|
||||
if (savedFixture === undefined) {
|
||||
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
|
||||
} else {
|
||||
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedFixture
|
||||
return
|
||||
}
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
})
|
||||
|
||||
it('returns packs when fetch is fresh', async () => {
|
||||
@@ -134,50 +125,4 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
@@ -10,42 +7,25 @@ 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))
|
||||
|
||||
/**
|
||||
* Determine whether the current build is a production Vercel deployment.
|
||||
*
|
||||
* @returns `true` if `process.env.VERCEL_ENV` is exactly `'production'`, `false` otherwise.
|
||||
*/
|
||||
function isProductionBuild(): boolean {
|
||||
return process.env.VERCEL_ENV === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a file:// URL pointing to a local fixture snapshot when WEBSITE_CLOUD_NODES_FIXTURE is set.
|
||||
*
|
||||
* @returns A `URL` for the resolved fixture path, or `undefined` if the environment variable is not set.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* The same resolved snapshot is used to derive both the site index and per-pack detail routes so static pages share a single source of truth. In production builds a stale snapshot causes the build to fail unless a local fixture override is provided via the `WEBSITE_CLOUD_NODES_FIXTURE` environment variable, which forces use of the on-disk snapshot instead of the live cloud API.
|
||||
* Used by both the index page and the per-pack detail pages so that the
|
||||
* static index and the static detail routes are always derived from the
|
||||
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
|
||||
* `inflight` promise, so repeated calls in the same build process share a
|
||||
* single network round-trip and the same outcome.
|
||||
*
|
||||
* @returns The array of `Pack` objects from the resolved snapshot to render at build time.
|
||||
* 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.
|
||||
*/
|
||||
export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
const snapshotUrl = fixtureSnapshotUrl()
|
||||
const options = snapshotUrl ? { snapshotUrl, apiKey: '' } : {}
|
||||
const outcome = await fetchCloudNodesForBuild(options)
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
reportCloudNodesOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
@@ -54,7 +34,7 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
)
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale' && isProductionBuild() && !snapshotUrl) {
|
||||
if (outcome.status === 'stale' && isProductionBuild()) {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
|
||||
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
|
||||
@@ -87,7 +87,6 @@ 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()
|
||||
@@ -95,21 +94,11 @@ 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()
|
||||
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
|
||||
}
|
||||
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
@@ -317,335 +306,4 @@ 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([
|
||||
[
|
||||
'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('prefers the first non-null registry result when every alias resolves', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map<string, unknown>([
|
||||
[
|
||||
'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',
|
||||
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 })
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
groupNodesByPack,
|
||||
sanitizeUserContent,
|
||||
slugifyPackId,
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
@@ -214,13 +213,6 @@ async function callOnce(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates a raw cloud nodes envelope into domain packs, enriches packs with registry metadata when available, and collects validation failures.
|
||||
*
|
||||
* @param envelope - Raw payload object from the cloud API keyed by node class name containing node definitions to validate and parse.
|
||||
* @param options - Fetch and behavior options used when resolving registry pack metadata (for example, `fetchImpl`).
|
||||
* @returns The `'ok'` outcome containing `packs` (an array of domain `Pack` objects) and `droppedNodes` (an array of `{ name, reason }` entries for definitions that failed validation).
|
||||
*/
|
||||
async function parseCloudNodes(
|
||||
envelope: Record<string, unknown>,
|
||||
options: FetchCloudNodesOptions
|
||||
@@ -245,12 +237,12 @@ async function parseCloudNodes(
|
||||
)
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
|
||||
const allAliases = grouped.flatMap((pack) => pack.rawIds)
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacks(allAliases, {
|
||||
fetchImpl: options.fetchImpl
|
||||
})
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
@@ -258,40 +250,15 @@ async function parseCloudNodes(
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.rawIds[0],
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
pickRegistryPack(registryMap, pack.rawIds)
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the most appropriate registry pack for a pack using its ordered aliases.
|
||||
*
|
||||
* @param registryMap - Map from alias to `RegistryPack` or explicit `null` indicating a known-but-empty entry
|
||||
* @param aliases - Ordered aliases to probe; earlier aliases have higher priority
|
||||
* @returns A `RegistryPack` if any alias maps to a non-null value; `null` if no alias had a non-null value but the first alias exists in the map with value `null`; `undefined` if the first alias is absent from the map
|
||||
*/
|
||||
function pickRegistryPack(
|
||||
registryMap: Map<string, RegistryPack | null>,
|
||||
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])
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize an external URL string.
|
||||
*
|
||||
* @param value - The input URL string to validate; may be `undefined`.
|
||||
* @returns The canonical `http` or `https` URL string if `value` is a valid absolute URL with a host, `undefined` otherwise.
|
||||
*/
|
||||
function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
@@ -304,19 +271,8 @@ function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed pack data and optional registry metadata into a domain `Pack`.
|
||||
*
|
||||
* @param packId - The canonical identifier to use for the pack
|
||||
* @param fallbackRegistryId - Registry id to use when `registryPack` does not provide one
|
||||
* @param fallbackDisplayName - Display name to use when `registryPack` does not provide a name
|
||||
* @param nodes - Array of node entries containing the class name and validated node definition
|
||||
* @param registryPack - Optional registry metadata for enriching pack fields; may be `null` or `undefined`
|
||||
* @returns A `Pack` with normalized fields, safe external URLs, optional publisher info, registry-derived metadata when available, and nodes converted to `PackNode` objects sorted by display name
|
||||
*/
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackRegistryId: string | undefined,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
className: string
|
||||
@@ -332,7 +288,7 @@ function toDomainPack(
|
||||
): Pack {
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id ?? fallbackRegistryId,
|
||||
registryId: registryPack?.id,
|
||||
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
|
||||
description: registryPack?.description?.trim() || undefined,
|
||||
bannerUrl: safeExternalUrl(registryPack?.banner_url),
|
||||
@@ -378,82 +334,22 @@ function toDomainNode(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate a nodes snapshot from a provided file URL or from the bundled snapshot, normalizing pack IDs.
|
||||
*
|
||||
* If `snapshotUrl` is provided, reads the file, parses JSON, and returns the snapshot after `isNodesSnapshot` validation and `normalizeSnapshotIds` normalization.
|
||||
* If `snapshotUrl` is omitted, validates and returns the bundled snapshot after normalization.
|
||||
* Returns `null` if reading, parsing, or validation fails.
|
||||
*
|
||||
* @param snapshotUrl - Optional file `URL` pointing to a snapshot JSON; when omitted the bundled snapshot is used
|
||||
* @returns The normalized `NodesSnapshot` if available and valid, `null` otherwise
|
||||
*/
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<NodesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isNodesSnapshot(bundledSnapshot)
|
||||
? normalizeSnapshotIds(bundledSnapshot)
|
||||
: null
|
||||
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isNodesSnapshot(parsed)) return normalizeSnapshotIds(parsed)
|
||||
if (isNodesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize pack IDs by slugifying each pack's `id`, omitting packs with empty slugs, and merging packs that produce the same slug.
|
||||
*
|
||||
* The returned snapshot preserves the original snapshot fields but replaces `packs` with a list whose `id` values are the slugified IDs. When multiple packs map to the same slug, their nodes and non-nullish metadata are merged into a single pack.
|
||||
*
|
||||
* @param snapshot - The snapshot whose pack IDs should be normalized and deduplicated
|
||||
* @returns A new `NodesSnapshot` with pack IDs replaced by slugs, colliding packs merged, and packs with falsy slugs removed
|
||||
*/
|
||||
function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot {
|
||||
const bySlug = new Map<string, Pack>()
|
||||
for (const pack of snapshot.packs) {
|
||||
const slug = slugifyPackId(pack.id)
|
||||
if (!slug) continue
|
||||
const existing = bySlug.get(slug)
|
||||
if (existing) {
|
||||
bySlug.set(slug, mergeCollidedPacks(existing, pack))
|
||||
continue
|
||||
}
|
||||
bySlug.set(slug, { ...pack, id: slug })
|
||||
}
|
||||
return { ...snapshot, packs: [...bySlug.values()] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two packs that represent the same logical pack by concatenating their nodes and filling any missing metadata from the later pack.
|
||||
*
|
||||
* @param first - The base pack whose values take precedence.
|
||||
* @param next - The colliding pack whose `nodes` are appended and whose non-null, non-`id` fields supply values only when `first` has them missing.
|
||||
* @returns A new `Pack` whose `nodes` are `first.nodes` followed by `next.nodes`, with other fields taken from `first` unless absent, in which case the corresponding value from `next` is used.
|
||||
*/
|
||||
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<keyof Pack, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause execution for the specified duration.
|
||||
*
|
||||
* @param ms - Duration to wait in milliseconds
|
||||
* @returns A promise that resolves with no value when the delay has elapsed
|
||||
*/
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
|
||||
7
global.d.ts
vendored
7
global.d.ts
vendored
@@ -11,6 +11,12 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface RewardfulGlobal {
|
||||
referral?: string
|
||||
affiliate?: { id?: string; token?: string; name?: string }
|
||||
campaign?: { id?: string; name?: string }
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
@@ -63,6 +69,7 @@ interface Window {
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
Rewardful?: RewardfulGlobal
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -51,67 +51,4 @@ 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'
|
||||
])
|
||||
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', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
import { slugifyPackId } from './slugifyPackId'
|
||||
|
||||
export interface PackedNode {
|
||||
className: string
|
||||
@@ -9,27 +8,10 @@ export interface PackedNode {
|
||||
|
||||
export interface NodePack {
|
||||
id: string
|
||||
rawId: string
|
||||
rawIds: string[]
|
||||
displayName: string
|
||||
nodes: PackedNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group custom Comfy node definitions into packs keyed by a slugified pack identifier.
|
||||
*
|
||||
* Processes the provided node definitions, selects those identified as custom nodes, extracts
|
||||
* a raw pack identifier from each definition's `python_module`, converts it to a slug, and
|
||||
* aggregates nodes that share the same slug into a single `NodePack`.
|
||||
*
|
||||
* @param defs - Map of class name to `ComfyNodeDef` objects to be grouped
|
||||
* @returns An array of `NodePack` objects sorted by `id` (ascending). Each `NodePack` includes:
|
||||
* - `id`: the slugified pack identifier
|
||||
* - `rawId`: the raw identifier extracted from a representative node's `python_module`
|
||||
* - `rawIds`: all distinct raw identifiers that were mapped to the same slug
|
||||
* - `displayName`: display text taken from the node source metadata
|
||||
* - `nodes`: the list of packed node entries (`{ className, def }`)
|
||||
*/
|
||||
export function groupNodesByPack(
|
||||
defs: Record<string, ComfyNodeDef>
|
||||
): NodePack[] {
|
||||
@@ -41,31 +23,21 @@ export function groupNodesByPack(
|
||||
continue
|
||||
}
|
||||
|
||||
const rawId = def.python_module.split('.')[1]?.split('@')[0]
|
||||
if (!rawId) {
|
||||
const packId = def.python_module.split('.')[1]?.split('@')[0]
|
||||
if (!packId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = slugifyPackId(rawId)
|
||||
if (!slug) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = byPackId.get(slug)
|
||||
const existing = byPackId.get(packId)
|
||||
const node = { className, def }
|
||||
|
||||
if (existing) {
|
||||
existing.nodes.push(node)
|
||||
if (!existing.rawIds.includes(rawId)) {
|
||||
existing.rawIds.push(rawId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
byPackId.set(slug, {
|
||||
id: slug,
|
||||
rawId,
|
||||
rawIds: [rawId],
|
||||
byPackId.set(packId, {
|
||||
id: packId,
|
||||
displayName: source.displayText,
|
||||
nodes: [node]
|
||||
})
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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, '')
|
||||
}
|
||||
@@ -2,4 +2,3 @@ export * from './schemas/nodeDefSchema'
|
||||
export * from './classifiers/nodeSource'
|
||||
export * from './helpers/groupNodesByPack'
|
||||
export * from './helpers/sanitizeUserContent'
|
||||
export * from './helpers/slugifyPackId'
|
||||
|
||||
20
src/base/wheelGestures.ts
Normal file
20
src/base/wheelGestures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -13,25 +13,18 @@ vi.mock('@/i18n', () => ({
|
||||
const executionStore = reactive<{
|
||||
isIdle: boolean
|
||||
executionProgress: number
|
||||
executingNode: unknown
|
||||
executingNode: null | {
|
||||
title?: string
|
||||
type?: string
|
||||
}
|
||||
executingNodeProgress: number
|
||||
nodeProgressStates: Record<string, unknown>
|
||||
activeJob: {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: { id: number; type: string }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}>({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null,
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {},
|
||||
activeJob: null
|
||||
nodeProgressStates: {}
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -75,7 +68,6 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executingNode = null
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activeJob = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
@@ -185,18 +177,12 @@ describe('useBrowserTabTitle', () => {
|
||||
it('shows node execution title when executing a node using nodeProgressStates', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.executingNode = {
|
||||
type: 'Foo'
|
||||
}
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
|
||||
}
|
||||
executionStore.activeJob = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: [{ id: 1, type: 'Foo' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
|
||||
@@ -74,14 +74,14 @@ export const useBrowserTabTitle = () => {
|
||||
}
|
||||
|
||||
// If only one node is running
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const [, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
const nodeLabel =
|
||||
executionStore.executingNode?.type?.trim() ||
|
||||
executionStore.executingNode?.title?.trim() ||
|
||||
'Node'
|
||||
|
||||
return `${executionText.value}[${progress}%] ${nodeType}`
|
||||
return `${executionText.value}[${progress}%] ${nodeLabel}`
|
||||
})
|
||||
|
||||
const workflowTitle = computed(
|
||||
|
||||
@@ -325,6 +325,7 @@ export interface CheckoutAttributionMetadata {
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
im_ref?: string
|
||||
rewardful_referral?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('getCheckoutAttribution', () => {
|
||||
}
|
||||
window.gtag = undefined
|
||||
window.ire = undefined
|
||||
window.Rewardful = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
@@ -228,4 +229,47 @@ describe('getCheckoutAttribution', () => {
|
||||
|
||||
expect(attribution.im_ref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures Rewardful referral from window.Rewardful', async () => {
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-abc-123'
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBe('rwd-abc-123')
|
||||
})
|
||||
|
||||
it('returns undefined Rewardful referral when window.Rewardful is absent', async () => {
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined Rewardful referral when window.Rewardful.referral is empty', async () => {
|
||||
window.Rewardful = { referral: '' }
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures Rewardful referral alongside Impact attribution', async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
'/?im_ref=impact-url-id&utm_source=affiliate'
|
||||
)
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-xyz-789'
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
im_ref: 'impact-url-id',
|
||||
utm_source: 'affiliate',
|
||||
rewardful_referral: 'rwd-xyz-789'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -180,6 +180,11 @@ async function getGeneratedClickId(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
function getRewardfulReferral(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
return asNonEmptyString(window.Rewardful?.referral)
|
||||
}
|
||||
|
||||
export function captureCheckoutAttributionFromSearch(search: string): void {
|
||||
const fromUrl = readAttributionFromUrl(search)
|
||||
const storedAttribution = readStoredAttribution()
|
||||
@@ -213,11 +218,13 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
}
|
||||
|
||||
const gaIdentity = await getGaIdentity()
|
||||
const rewardfulReferral = getRewardfulReferral()
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number
|
||||
ga_session_number: gaIdentity?.session_number,
|
||||
rewardful_referral: rewardfulReferral
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,17 @@ function createMockPointerEvent(
|
||||
return mockEvent as PointerEvent
|
||||
}
|
||||
|
||||
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
|
||||
function createMockWheelEvent(
|
||||
ctrlKey = false,
|
||||
metaKey = false,
|
||||
deltaX = 0,
|
||||
deltaY = 0
|
||||
): WheelEvent {
|
||||
const mockEvent: Partial<WheelEvent> = {
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
deltaX,
|
||||
deltaY,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad pinch-zoom inside a focused textarea must not
|
||||
* fall through to browser page zoom in non-standard navigation modes. */
|
||||
it.for(['legacy', 'custom'])(
|
||||
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should forward meta+wheel to canvas when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad two-finger horizontal swipes inside a focused
|
||||
* textarea must not fall through to browser back/forward navigation. */
|
||||
it.for(['standard', 'legacy', 'custom'])(
|
||||
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 30, 5)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 0, 30)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
|
||||
return !!(captureElement && active && captureElement.contains(active))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward to canvas when the event is not consumed by a focused widget,
|
||||
* or when it is a canvas gesture (which must override widget consumption
|
||||
* to prevent destructive browser defaults).
|
||||
*/
|
||||
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
|
||||
!wheelCapturedByFocusedElement(event) ||
|
||||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
|
||||
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
|
||||
* mode; all wheel events in legacy mode).
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!shouldForwardWheelEvent(event)) return
|
||||
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
forwardEventToCanvas(event)
|
||||
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
|
||||
// vertical wheel falls through so the document/widget scrolls normally.
|
||||
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
|
||||
// that function also returns true for unfocused vertical wheel (its
|
||||
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
|
||||
if (isStandardNavMode.value) {
|
||||
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
|
||||
forwardEventToCanvas(event)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
|
||||
})
|
||||
|
||||
it('has data-capture-wheel="true" on the root element', () => {
|
||||
const { container } = render(FormDropdownMenu, {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
container.firstElementChild!.getAttribute('data-capture-wheel')
|
||||
screen
|
||||
.getByTestId('form-dropdown-menu')
|
||||
.getAttribute('data-capture-wheel')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
/** Regression: PrimeVue Popover teleports the menu to document.body, so
|
||||
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
|
||||
* itself rather than relying on the LGraphNode wheel handler. */
|
||||
it.for([
|
||||
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
|
||||
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
|
||||
])('suppresses browser default for $name', ({ overrides }) => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
Object.defineProperty(event, key, { value })
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
/** Vertical scrolling must remain native so the dropdown's own scroll
|
||||
* container can scroll its content. */
|
||||
it('does not suppress vertical scroll', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: 30,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
|
||||
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
|
||||
* Suppress only the destructive browser defaults (page zoom on pinch and
|
||||
* back/forward on horizontal swipe); regular vertical scrolling still
|
||||
* scrolls the dropdown's own content.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (isCanvasGestureWheel(event)) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
|
||||
data-capture-wheel="true"
|
||||
data-testid="form-dropdown-menu"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
|
||||
@@ -1632,6 +1632,7 @@ export class ComfyApp {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
|
||||
// Create mock functions that will be shared
|
||||
const {
|
||||
@@ -18,10 +22,6 @@ const {
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn()
|
||||
}))
|
||||
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -70,7 +70,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokePreviewsByExecutionId: vi.fn()
|
||||
})
|
||||
@@ -94,6 +94,26 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function createQueuedWorkflow(path: string = 'workflows/test.json') {
|
||||
return {
|
||||
activeState: { id: 'workflow-id' },
|
||||
initialState: { id: 'workflow-id' },
|
||||
path
|
||||
} as Parameters<
|
||||
ReturnType<typeof useExecutionStore>['storeJob']
|
||||
>[0]['workflow']
|
||||
}
|
||||
|
||||
function createPromptNode(title: string, classType: string) {
|
||||
return {
|
||||
inputs: {},
|
||||
class_type: classType,
|
||||
_meta: {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
@@ -709,6 +729,103 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - executingNode with subgraphs', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should find executing node info in root graph from queued prompt data', () => {
|
||||
store.storeJob({
|
||||
id: 'test-prompt',
|
||||
nodes: ['123'],
|
||||
promptOutput: {
|
||||
'123': createPromptNode('Test Node', 'TestNode')
|
||||
},
|
||||
workflow: createQueuedWorkflow()
|
||||
})
|
||||
store.activeJobId = 'test-prompt'
|
||||
|
||||
store.nodeProgressStates = {
|
||||
'123': {
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 100,
|
||||
display_node_id: '123',
|
||||
prompt_id: 'test-prompt',
|
||||
node_id: '123'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.executingNode).toEqual({
|
||||
title: 'Test Node',
|
||||
type: 'TestNode'
|
||||
})
|
||||
})
|
||||
|
||||
it('should find executing node info in subgraph using execution ID', () => {
|
||||
store.storeJob({
|
||||
id: 'test-prompt',
|
||||
nodes: ['456:789'],
|
||||
promptOutput: {
|
||||
'456:789': createPromptNode('Nested Node', 'NestedNode')
|
||||
},
|
||||
workflow: createQueuedWorkflow()
|
||||
})
|
||||
store.activeJobId = 'test-prompt'
|
||||
|
||||
store.nodeProgressStates = {
|
||||
'456:789': {
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 100,
|
||||
display_node_id: '456:789',
|
||||
prompt_id: 'test-prompt',
|
||||
node_id: '456:789'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.executingNode).toEqual({
|
||||
title: 'Nested Node',
|
||||
type: 'NestedNode'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when no node is executing', () => {
|
||||
store.nodeProgressStates = {}
|
||||
|
||||
expect(store.executingNode).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when executing node metadata cannot be found', () => {
|
||||
store.storeJob({
|
||||
id: 'test-prompt',
|
||||
nodes: ['123'],
|
||||
promptOutput: {
|
||||
'123': createPromptNode('Test Node', 'TestNode')
|
||||
},
|
||||
workflow: createQueuedWorkflow()
|
||||
})
|
||||
store.activeJobId = 'test-prompt'
|
||||
|
||||
store.nodeProgressStates = {
|
||||
'999': {
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 100,
|
||||
display_node_id: '999',
|
||||
prompt_id: 'test-prompt',
|
||||
node_id: '999'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.executingNode).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
|
||||
let store: ReturnType<typeof useMissingNodesErrorStore>
|
||||
|
||||
@@ -1076,9 +1193,21 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
path: '/workflows/foo.json'
|
||||
} as unknown as Parameters<typeof store.storeJob>[0]['workflow']
|
||||
|
||||
store.storeJob({ nodes: ['a', 'b'], id: 'job-1', workflow })
|
||||
store.storeJob({
|
||||
nodes: ['a', 'b'],
|
||||
id: 'job-1',
|
||||
promptOutput: {
|
||||
a: createPromptNode('Node A', 'NodeA'),
|
||||
b: createPromptNode('Node B', 'NodeB')
|
||||
},
|
||||
workflow
|
||||
})
|
||||
|
||||
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false })
|
||||
expect(store.queuedJobs['job-1']?.nodeLookup).toEqual({
|
||||
a: { title: 'Node A', type: 'NodeA' },
|
||||
b: { title: 'Node B', type: 'NodeB' }
|
||||
})
|
||||
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
|
||||
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON,
|
||||
ComfyApiWorkflow,
|
||||
NodeId,
|
||||
WorkflowId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -36,6 +35,11 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface ExecutionNodeInfo {
|
||||
title?: string | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
interface QueuedJob {
|
||||
/**
|
||||
* The nodes that are queued to be executed. The key is the node id and the
|
||||
@@ -46,6 +50,25 @@ interface QueuedJob {
|
||||
* The workflow that is queued to be executed
|
||||
*/
|
||||
workflow?: ComfyWorkflow
|
||||
/**
|
||||
* Queue-time node metadata keyed by execution ID.
|
||||
* This stays stable even if the user switches workflows or edits the canvas.
|
||||
*/
|
||||
nodeLookup?: Record<string, ExecutionNodeInfo>
|
||||
}
|
||||
|
||||
function buildExecutionNodeLookup(
|
||||
promptOutput: ComfyApiWorkflow
|
||||
): Record<string, ExecutionNodeInfo> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(promptOutput).map(([executionId, node]) => [
|
||||
executionId,
|
||||
{
|
||||
title: node._meta.title,
|
||||
type: node.class_type
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,21 +191,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => new Set(executingNodeIds.value.map(String))
|
||||
)
|
||||
|
||||
// For backward compatibility - returns the primary executing node
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
// For backward compatibility - returns the primary executing node info
|
||||
const executingNode = computed<ExecutionNodeInfo | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
|
||||
if (!workflow) return null
|
||||
|
||||
const canvasState: ComfyWorkflowJSON | null =
|
||||
workflow.changeTracker?.activeState ?? null
|
||||
if (!canvasState) return null
|
||||
|
||||
return (
|
||||
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
|
||||
null
|
||||
)
|
||||
return activeJob.value?.nodeLookup?.[String(executingNodeId.value)] ?? null
|
||||
})
|
||||
|
||||
// This is the progress of the currently executing node (for backward compatibility)
|
||||
@@ -548,10 +561,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
function storeJob({
|
||||
nodes,
|
||||
id,
|
||||
promptOutput,
|
||||
workflow
|
||||
}: {
|
||||
nodes: string[]
|
||||
id: JobId
|
||||
promptOutput: ComfyApiWorkflow
|
||||
workflow: ComfyWorkflow
|
||||
}) {
|
||||
queuedJobs.value[id] ??= { nodes: {} }
|
||||
@@ -563,6 +578,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}, {}),
|
||||
...queuedJob.nodes
|
||||
}
|
||||
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
|
||||
queuedJob.workflow = workflow
|
||||
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
|
||||
if (wid) {
|
||||
|
||||
Reference in New Issue
Block a user