Compare commits

..

3 Commits

Author SHA1 Message Date
Austin
6e4b8619fc Fix test 2026-05-18 20:27:40 -07:00
Austin
38248a6040 Add test 2026-05-18 19:55:39 -07:00
Austin
8e67b83fa8 Fix restoring values to dynamic combos 2026-05-18 19:55:38 -07:00
257 changed files with 5437 additions and 21541 deletions

View File

@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -45,8 +45,12 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: pnpm build:cloud
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -59,7 +59,7 @@ jobs:
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
pnpm build
NX_SKIP_NX_CACHE=true pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available globally"
echo "TypeScript and Vue compiler already available locally"
fi
- name: Run Claude Documentation Review

5
.gitignore vendored
View File

@@ -19,7 +19,6 @@ yarn.lock
node_modules
.pnpm-store
.nx
dist
dist-ssr
*.local
@@ -90,6 +89,10 @@ storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

1
.nxignore Normal file
View File

@@ -0,0 +1 @@
.claude/worktrees

View File

@@ -2,6 +2,7 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",

View File

@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
## Monorepo Architecture
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
The project uses **Nx** for build orchestration and task management
## Package Manager
@@ -237,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records

View File

@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[pnpm dev hangs]
B -->|Dev server stuck| C[nx serve hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
@@ -23,7 +23,7 @@ flowchart TD
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
@@ -41,11 +41,11 @@ flowchart TD
### Development Server Issues
#### Q: `pnpm dev` gets stuck and won't start
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
**Symptoms:**
- Command hangs during Vite startup
- Command hangs on "nx serve"
- Dev server doesn't respond
- Terminal appears frozen
@@ -65,7 +65,7 @@ flowchart TD
3. **Last resort - Full node_modules reset:**
```bash
pnpm clean:all && pnpm i
pnpm dlx rimraf node_modules && pnpm i
```
**Why this happens:**
@@ -73,7 +73,7 @@ flowchart TD
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- stale local build cache
- NX cache corruption
---

View File

@@ -3,11 +3,8 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
"lint": "eslint src --cache",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
@@ -36,5 +33,88 @@
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {
"tags": [
"scope:desktop",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite preview --config vite.config.mts"
}
},
"storybook": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook dev -p 6007"
}
},
"build-storybook": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook build -o dist/storybook"
},
"outputs": [
"{projectRoot}/dist/storybook"
]
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "eslint src --cache"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vue-tsc --noEmit -p tsconfig.json"
}
}
}
}
}

View File

@@ -45,5 +45,88 @@
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<GlassCard
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -74,7 +74,7 @@ useHeroAnimation({
</div>
<!-- Video -->
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/about/co-founders.webm"
poster="https://media.comfy.org/website/about/co-founders-poster.webp"

View File

@@ -33,7 +33,7 @@ const values: {
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<SectionLabel>
{{ t('about.values.label', locale) }}

View File

@@ -16,7 +16,7 @@ const investors = [
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"

View File

@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
<template #right-card>
<img

View File

@@ -41,7 +41,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<section class="px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -46,9 +46,7 @@ const cards = excludeProduct
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
>
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
<!-- Header -->
<div class="flex flex-col items-center px-4 text-center">
<SectionLabel v-if="labelKey">

View File

@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<section class="px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<div
ref="trackRef"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
>
<div
v-for="(fb, i) in feedbacks"

View File

@@ -72,7 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<template>
<section
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in customerStories"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
<section class="px-6 py-16 lg:px-20 lg:py-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/silverside/video.webm"
poster="https://media.comfy.org/website/customers/silverside/poster.webp"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"

View File

@@ -223,10 +223,7 @@ while (idx < items.length) {
</script>
<template>
<section
data-testid="gallery-grid"
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
>
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
<!-- Desktop grid -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"

View File

@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<SectionLabel>
{{ t('gallery.label', locale) }}
</SectionLabel>

View File

@@ -15,7 +15,7 @@ const row2 = [
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
class="bg-primary-comfy-ink flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
>
<!-- Node rows -->
<div

View File

@@ -12,9 +12,7 @@ const routes = getRoutes(locale)
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<GlassCard
class="flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -36,9 +36,7 @@ const steps = [
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<div class="flex flex-col gap-12 lg:flex-row lg:gap-8">
<!-- Left heading -->
<div

View File

@@ -15,7 +15,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
<template>
<section
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"

View File

@@ -55,10 +55,7 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section
ref="sectionRef"
class="max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />

View File

@@ -121,7 +121,7 @@ const activePlanIndex = ref(0)
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<section class="px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
@@ -135,7 +135,7 @@ const activePlanIndex = ref(0)
</div>
<!-- Mobile plan tabs -->
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"

View File

@@ -60,7 +60,7 @@ const features: IncludedFeature[] = [
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<section class="px-4 py-16 lg:px-20 lg:py-24">
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div

View File

@@ -25,7 +25,7 @@ const cards = [
</script>
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<section class="px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="bg-transparency-white-t4 rounded-5xl max-w-9xl mx-auto mt-4 mb-24 p-2 px-4 lg:mt-8 lg:mb-40 lg:px-20"
class="bg-transparency-white-t4 rounded-5xl mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
>
<div
class="bg-primary-comfy-yellow flex flex-col gap-24 rounded-4xl p-8 lg:flex-row lg:items-end lg:justify-between"

View File

@@ -442,7 +442,7 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<section class="px-4 py-24 lg:px-20">
<GlassCard
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-16"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40">
<section class="px-4 py-24 lg:px-20 lg:py-40">
<div
class="bg-transparency-white-t4 rounded-5xl flex flex-col-reverse items-stretch gap-10 p-2 lg:flex-row lg:gap-8"
>

View File

@@ -77,9 +77,7 @@ function getCardClass(layoutClass: string): string {
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"

View File

@@ -11,7 +11,7 @@ defineProps<{
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<section class="px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template v-if="subtitle" #subtitle>

View File

@@ -22,7 +22,7 @@ defineProps<{
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<section class="px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template #subtitle>

View File

@@ -29,7 +29,7 @@ const {
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
>
<!-- Left heading -->
<div

View File

@@ -1,5 +1,5 @@
{
"fetchedAt": "2026-05-22T00:07:48.353Z",
"fetchedAt": "2026-05-12T16:10:34.114Z",
"departments": [
{
"name": "DESIGN",
@@ -36,14 +36,14 @@
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
@@ -71,14 +71,14 @@
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
@@ -105,21 +105,21 @@
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
@@ -135,20 +135,6 @@
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
}
]
},

File diff suppressed because one or more lines are too long

View File

@@ -72,9 +72,6 @@ const websiteJsonLd = {
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -2,8 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks,
fetchRegistryPacksWithNodes
fetchRegistryPacks
} from './cloudNodes.registry'
function jsonResponse(
@@ -143,315 +142,3 @@ describe('fetchRegistryPacks', () => {
expect(result.size).toBe(0)
})
})
describe('fetchRegistryPacksWithNodes', () => {
it('fetches pack metadata and comfy nodes for each pack', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
// Pack metadata request
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
}
]
})
}
// Comfy nodes request
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(1)
const packData = result.get('comfyui-impact-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
expect(packData?.nodes).toHaveLength(2)
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
})
it('handles pagination for comfy nodes', async () => {
let comfyNodesCallCount = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'big-pack',
name: 'Big Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesCallCount++
const page = Number(url.searchParams.get('page') ?? '1')
if (page === 1) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'Node1', category: 'cat1' },
{ comfy_node_name: 'Node2', category: 'cat1' }
],
totalNumberOfPages: 2
})
} else {
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
totalNumberOfPages: 2
})
}
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesCallCount).toBe(2)
const packData = result.get('big-pack')
expect(packData?.nodes).toHaveLength(3)
})
it('returns null for packs without latest_version', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'no-version-pack',
name: 'No Version Pack',
latest_version: null
}
]
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.get('no-version-pack')).toBeNull()
})
it('returns empty nodes array when comfy-nodes request fails', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'failing-pack',
name: 'Failing Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return new Response('Server error', { status: 500 })
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('failing-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('Failing Pack')
expect(packData?.nodes).toHaveLength(0)
})
it('handles null comfy_nodes in response', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'null-nodes-pack',
name: 'Null Nodes Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: null,
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('null-nodes-pack')
expect(packData?.nodes).toHaveLength(0)
})
it('fetches nodes for multiple packs in parallel', async () => {
const packIds = ['pack-a', 'pack-b', 'pack-c']
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
const requestedIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: requestedIds.map((id) => ({
id,
name: id.toUpperCase(),
latest_version: { version: '1.0.0' }
}))
})
}
if (url.pathname.includes('/comfy-nodes')) {
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: `${packId}-node`, category: 'test' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(3)
for (const packId of packIds) {
const packData = result.get(packId)
expect(packData).not.toBeNull()
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
}
})
it('retries comfy-nodes fetch once on failure', async () => {
let comfyNodesAttempts = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'retry-pack',
name: 'Retry Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesAttempts++
if (comfyNodesAttempts === 1) {
return new Response('Server error', { status: 500 })
}
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesAttempts).toBe(2)
const packData = result.get('retry-pack')
expect(packData?.nodes).toHaveLength(1)
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
})
it('normalizes null boolean fields in comfy nodes', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'bool-pack',
name: 'Bool Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{
comfy_node_name: 'TestNode',
category: 'test',
deprecated: null,
experimental: null
}
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('bool-pack')
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
expect(packData?.nodes[0]?.experimental).toBeUndefined()
})
})

View File

@@ -5,10 +5,8 @@ import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
const COMFY_NODES_PAGE_SIZE = 500
export type RegistryPack = components['schemas']['Node']
export type RegistryComfyNode = components['schemas']['ComfyNode']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
@@ -60,29 +58,6 @@ const RegistryListResponseSchema = z
})
.passthrough()
const RegistryComfyNodeSchema = z
.object({
comfy_node_name: optionalString,
category: optionalString,
description: optionalString,
deprecated: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
experimental: z
.boolean()
.nullish()
.transform((v) => v ?? undefined)
})
.passthrough()
const RegistryComfyNodesResponseSchema = z
.object({
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
totalNumberOfPages: z.number().nullish()
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
@@ -147,142 +122,6 @@ export async function fetchRegistryPacks(
return resolved
}
export interface RegistryPackWithNodes {
pack: RegistryPack
nodes: RegistryComfyNode[]
}
export async function fetchRegistryPacksWithNodes(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPackWithNodes | null>> {
const packs = await fetchRegistryPacks(packIds, options)
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const entries = await Promise.all(
[...packs.entries()].map(
async ([packId, pack]): Promise<
[string, RegistryPackWithNodes | null]
> => {
if (!pack?.latest_version?.version) {
return [packId, null]
}
const nodes = await fetchComfyNodesForPack(
fetchImpl,
baseUrl,
packId,
pack.latest_version.version,
timeoutMs
)
return [packId, { pack, nodes }]
}
)
)
return new Map(entries)
}
async function fetchComfyNodesForPack(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
timeoutMs: number
): Promise<RegistryComfyNode[]> {
const allNodes: RegistryComfyNode[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const result = await fetchComfyNodesPageWithRetry(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (!result) break
allNodes.push(...result.nodes)
totalPages = result.totalPages
page++
}
return allNodes
}
async function fetchComfyNodesPageWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const firstAttempt = await fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (firstAttempt) return firstAttempt
// Retry once on failure
return fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
}
async function fetchComfyNodesPage(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) return null
const rawBody: unknown = await res.json()
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
if (!parsed.success) return null
return {
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
totalPages: parsed.data.totalNumberOfPages ?? 1
}
} catch {
return null
} finally {
clearTimeout(timer)
}
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,

View File

@@ -8,16 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
import type { RegistryPackWithNodes } from './cloudNodes.registry'
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
)
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
fetchRegistryPacks: fetchRegistryPacksMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
@@ -94,8 +90,8 @@ describe('fetchCloudNodesForBuild', () => {
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksWithNodesMock.mockReset()
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
@@ -106,21 +102,14 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksWithNodesMock.mockResolvedValue(
new Map<string, RegistryPackWithNodes | null>([
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'comfyui-impact-pack',
{
pack: {
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '1.0.0' }
},
nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
]
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
}
]
])
@@ -140,10 +129,6 @@ describe('fetchCloudNodesForBuild', () => {
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
// Nodes should come from registry, not object_info
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
@@ -312,7 +297,7 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
@@ -320,8 +305,5 @@ describe('fetchCloudNodesForBuild', () => {
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
// Falls back to object_info nodes when registry fails
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
})
})

View File

@@ -6,15 +6,12 @@ import {
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type {
RegistryComfyNode,
RegistryPackWithNodes
} from './cloudNodes.registry'
import type { RegistryPack } from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
@@ -238,28 +235,26 @@ async function parseCloudNodes(
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
// Use object_info to determine which packs are cloud-supported
const grouped = groupNodesByPack(sanitizedDefs)
const packIds = grouped.map((pack) => pack.id)
// Fetch full pack metadata and node list from registry
let registryMap = new Map<string, RegistryPackWithNodes | null>()
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: options.fetchImpl
})
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
const packs = grouped
.map((pack) => {
const registryData = registryMap.get(pack.id)
// Use registry nodes if available, otherwise fall back to object_info nodes
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
})
.filter((pack) => pack.nodes.length > 0)
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
@@ -279,7 +274,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
function toDomainPack(
packId: string,
fallbackDisplayName: string,
objectInfoNodes: Array<{
nodes: Array<{
className: string
def: {
display_name: string
@@ -289,18 +284,8 @@ function toDomainPack(
experimental?: boolean
}
}>,
registryData: RegistryPackWithNodes | null | undefined
registryPack: RegistryPack | null | undefined
): Pack {
const registryPack = registryData?.pack
// Prefer registry nodes if available, fall back to object_info nodes
const nodes =
registryData?.nodes && registryData.nodes.length > 0
? registryData.nodes
.map((node) => toDomainNodeFromRegistry(node))
.filter((n): n is PackNode => n !== null)
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
return {
id: packId,
registryId: registryPack?.id,
@@ -323,20 +308,9 @@ function toDomainPack(
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
if (!node.comfy_node_name) return null
return {
name: node.comfy_node_name,
displayName: node.comfy_node_name,
category: node.category || '',
description: node.description || undefined,
deprecated: node.deprecated,
experimental: node.experimental
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}

View File

@@ -1,10 +1,8 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
@@ -86,12 +84,11 @@ export class ComfyNodeSearchBoxV2 {
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(position?: Position) {
const { x, y } = position ?? { x: 200, y: 200 }
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
@@ -112,14 +109,4 @@ export class ComfyNodeSearchBoxV2 {
'search box'
)
}
async addNode(query: string, options: { position?: Position } = {}) {
const position = options.position ?? { x: 200, y: 200 }
await this.openByDoubleClickCanvas(position)
await this.input.fill(query)
await expect(this.results.first()).toContainText(query)
await this.comfyPage.page.keyboard.press('Enter')
await expect(this.dialog).toBeHidden()
await this.comfyPage.page.mouse.click(position.x, position.y)
}
}

View File

@@ -2,24 +2,10 @@ import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
public readonly trigger: Locator
constructor(public readonly root: Locator) {
this.trigger = root.locator('button:has(> span)').first()
this.selection = root.locator('button span span')
}
async open(): Promise<void> {
await this.trigger.click()
}
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
await this.open()
const searchInput = popover.getByRole('textbox')
await searchInput.fill(query)
await searchInput.press('Enter')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
@@ -25,11 +24,6 @@ export class AppModeWidgetHelper {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)

View File

@@ -11,7 +11,6 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
@@ -242,17 +241,6 @@ export class SubgraphHelper {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
async getInputBounds(): Promise<Position & Size> {
return await this.comfyPage.page.evaluate(() => {
const graph = app!.canvas.graph as Subgraph
const inputNode = graph.inputNode
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
const width = inputNode.size[0] * app!.canvas.ds.scale
const height = inputNode.size[1] * app!.canvas.ds.scale
return { x, y, width, height }
})
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.

View File

@@ -62,39 +62,12 @@ export class WorkflowHelper {
async waitForDraftPersisted() {
await this.comfyPage.page.waitForFunction(() =>
Object.keys(localStorage).some((key) =>
key.startsWith('Comfy.Workflow.Draft.v2:')
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
)
)
}
/** Waits for V2 draft index recency, not payload content freshness. */
async waitForDraftIndexUpdatedSince(updatedSince: number) {
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (
typeof index.updatedAt === 'number' &&
index.updatedAt >= indexUpdatedSince
) {
return true
}
} catch {
// Ignore malformed storage while waiting for persistence.
}
}
return false
}, updatedSince)
}
/**
* Reloads the current page and waits for the app to initialize.
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and

View File

@@ -8,7 +8,6 @@ import {
} from '@comfyorg/ingest-types/zod'
import type {
JobDetail,
JobStatus,
RawJobListItem,
zJobsListResponse
@@ -41,7 +40,7 @@ interface JobsListRoute {
responseLimit?: number
}
export interface JobsScenario {
interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
@@ -183,24 +182,6 @@ export class JobsRouteMocker {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
await this.page.route(
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: detail })
}
)
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,

View File

@@ -76,15 +76,7 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
cloudNotification: 'cloud-notification-dialog'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

@@ -1,250 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -1,28 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -2,10 +2,16 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
@@ -19,12 +25,15 @@ test.describe('App mode usage', () => {
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
@@ -98,45 +107,6 @@ test.describe('App mode usage', () => {
//verify values are consistent with litegraph
})
test('FormDropdown search Enter selects the top filtered item', async ({
comfyPage
}) => {
await comfyPage.appMode.enableLinearMode()
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
await comfyPage.nextFrame()
const fileComboWidget = await loadImageNode.getWidget(0)
const targetImage = String(await fileComboWidget.getValue())
const initialImage = 'not-selected.png'
await comfyPage.page.evaluate(
([nodeId, value]) => {
const node = window.app!.graph!.getNodeById(nodeId)
const widget = node?.widgets?.[0]
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
widget.value = value
},
[loadImageNode.id, initialImage] as const
)
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
await comfyPage.appMode.enterAppModeWithInputs([
[String(loadImageNode.id), 'image']
])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageNode.id}:image`
)
const popover = comfyPage.appMode.imagePickerPopover
await expect(imageInput.root).toBeVisible()
await imageInput.searchAndSelectTop(popover, targetImage)
await expect(popover).toBeHidden()
await expect(imageInput.selection).toHaveText(targetImage)
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -75,28 +75,33 @@ test.describe('App mode builder selection', () => {
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas is initially editable'
).toBeVisible()
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toBeHidden()
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toBeHidden()
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
@@ -107,10 +112,10 @@ test.describe('App mode builder selection', () => {
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toBeVisible()
).toHaveCount(1)
})
})

View File

@@ -131,10 +131,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageId}:image`
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
)
await imageInput.open()
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").

View File

@@ -361,15 +361,3 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
})

View File

@@ -1,5 +1,4 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
@@ -44,45 +43,4 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
})
test('Spinner persists until workflow loaded', async ({
page,
request
}, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
const username = `playwright-test-${parallelIndex}`
const userId = await comfyPage.setupUser(username)
comfyPage.userIds[parallelIndex] = userId
await page.goto(`${comfyPage.url}/api/users`)
await page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
const splash = page.locator('#splash-loader')
let notifyWorkflowRequested!: () => void
const workflowRequested = new Promise<void>(
(r) => (notifyWorkflowRequested = r)
)
let unblockRequest!: () => void
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
await page.route('**/templates/default.json', async (route) => {
notifyWorkflowRequested()
await requestUnblocked
return route.continue()
})
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
await workflowRequested
await comfyPage.nextFrame()
await expect(splash).toBeVisible()
unblockRequest()
await expect(splash).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,65 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -1,38 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -19,8 +18,61 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
@@ -40,14 +92,11 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -1,147 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.use({
initialSettings: {
'Comfy.RightSidePanel.ShowErrorsTab': true
}
})
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -10,9 +10,6 @@ import type {
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------

View File

@@ -1,278 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
interface ViewFile {
body?: Buffer | string
contentType?: string
}
type ViewFilesByName = Readonly<Record<string, ViewFile>>
const transparentPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
'base64'
)
const alphaJob = createRouteMockJob({
id: 'alpha',
create_time: routeMockJobTimestamp - 1_000,
execution_start_time: routeMockJobTimestamp - 1_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'alpha.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
const betaJob = createRouteMockJob({
id: 'beta',
create_time: routeMockJobTimestamp - 2_000,
execution_start_time: routeMockJobTimestamp - 2_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'beta.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
}
})
const multiOutputJob = createRouteMockJob({
id: 'multi-output',
create_time: routeMockJobTimestamp - 3_000,
execution_start_time: routeMockJobTimestamp - 3_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'multi-output-a.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
const multiOutputJobDetail: JobDetail = {
...multiOutputJob,
outputs: {
'3': {
images: [
{
filename: 'multi-output-a.png',
subfolder: '',
type: 'output'
},
{
filename: 'multi-output-b.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
const viewFiles = {
'alpha.png': {},
'beta.png': {},
'imported.png': {},
'multi-output-a.png': {},
'multi-output-b.png': {}
}
async function mockInputFiles(page: Page, files: readonly string[]) {
await page.route('**/internal/files/input**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({ json: [...files] })
})
}
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
await page.route('**/api/view**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
json: { error: 'Missing filename' } satisfies { error: string }
})
return
}
const file = filesByName[filename]
if (!file) {
await route.fulfill({
status: 404,
json: {
error: `Unknown filename: ${filename}`
} satisfies { error: string }
})
return
}
await route.fulfill({
body: file.body ?? transparentPng,
contentType: file.contentType ?? 'image/png'
})
})
}
test.describe('FE-130 assets sidebar route mocks', () => {
test.beforeEach(async ({ jobsRoutes, page }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
})
test('renders generated and imported assets with image previews', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
await expect(tab.getAssetCardByName('beta')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'alpha.png' })
).toHaveJSProperty('naturalWidth', 1)
await tab.switchToImported()
await expect(tab.getAssetCardByName('imported')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'imported.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('opens previews for generated and imported images', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'alpha.png'
})
).toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
await tab.switchToImported()
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'imported.png'
})
).toBeVisible()
})
test('shows footer actions for single and multiple generated selections', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
await comfyPage.page.keyboard.down('Control')
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})
test('loads full generated job outputs from job detail', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await jobsRoutes.mockJobsHistory([multiOutputJob])
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
await comfyPage.setup()
await tab.open()
await tab
.getAssetCardByName('multi-output-a')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('deletes a generated output asset through explicit history refresh', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
const deleteRequests = await jobsRoutes.mockDeleteHistory()
await jobsRoutes.mockJobsHistory([betaJob])
await tab.getAssetCardByName('alpha').click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
await comfyPage.confirmDialog.delete.click()
await expect.poll(() => deleteRequests).toHaveLength(1)
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
await expect(comfyPage.toast.toastSuccesses).toContainText(
'Asset deleted successfully'
)
})
})

View File

@@ -7,10 +7,12 @@ import {
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
const historyJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'history-completed',
@@ -73,24 +75,21 @@ const activeJobs: RawJobListItem[] = [
]
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
initialJobsScenario: JobsScenario
mockInitialJobsScenario: void
}>({
initialJobsScenario: [
{ history: historyJobs, queue: activeJobs },
{ option: true }
],
mockInitialJobsScenario: [
async ({ jobsRoutes, initialJobsScenario }, use) => {
await jobsRoutes.mockJobsScenario(initialJobsScenario)
await use()
},
{ auto: true }
]
})
async function setupJobHistorySidebar(
comfyPage: ComfyPage,
jobsRoutes: JobsRouteMocker,
{
history = historyJobs,
queue = activeJobs
}: {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
} = {}
) {
await jobsRoutes.mockJobsScenario({ history, queue })
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup()
async function openJobHistorySidebar(comfyPage: ComfyPage) {
await comfyPage.page
.getByTestId(TestIds.sidebar.toolbar)
.getByRole('button', { name: 'Job History', exact: true })
@@ -123,152 +122,159 @@ async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
}
test.describe('Job history sidebar', { tag: '@ui' }, () => {
test.describe('docked overlay action', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
test('opens from the queue overlay docked history action', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
test('opens from the queue overlay docked history action', async ({
comfyPage,
jobsRoutes
}) => {
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: activeJobs
})
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
})
test.describe('expanded history tab', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
test('shows terminal and active job states', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
test('shows terminal and active job states', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
test('filters completed and failed history jobs', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
test('searches by job id and output filename', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
})
await clearQueueButton(comfyPage).click()
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
})
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
test.describe('without pending queue jobs', () => {
test.use({
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
initialSettings: { 'Comfy.Queue.QPOV2': true }
test('filters completed and failed history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
test('searches by job id and output filename', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
test('disables clear queue when there are no pending jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes, {
queue: runningOnlyJobs
})
test('disables clear queue', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await expect(clearQueueButton(comfyPage)).toBeDisabled()
})
await expect(clearQueueButton(comfyPage)).toBeDisabled()
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
})
await clearQueueButton(comfyPage).click()
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await setupJobHistorySidebar(comfyPage, jobsRoutes)
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
})
})

View File

@@ -129,26 +129,4 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
test('Click-to-place from sidebar selects the newly added node', async ({
comfyPage
}) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await comfyPage.nodeOps.clearGraph()
await tab.expandFolder('sampling')
const canvasBox = (await comfyPage.canvas.boundingBox())!
const target = {
x: canvasBox.width / 2,
y: canvasBox.height / 2
}
await tab.getNode('KSampler (Advanced)').click()
await comfyPage.canvas.click({ position: target })
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
})
})

View File

@@ -5,6 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Clipboard Operations', () => {
@@ -54,7 +58,8 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.searchBoxV2.addNode('Note')
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
const initialCount = await comfyPage.subgraph.getNodeCount()

View File

@@ -745,19 +745,20 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
})
await test.step('Convert both nodes to subgraph', async () => {

View File

@@ -632,72 +632,3 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
})
})
})
test(
'link interactions',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const seedSlot = ksampler.getSlot('seed')
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
await test.step('Make second INT typed connection', async () => {
const toPos = await seedIOSlot.getOpenSlotPosition()
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
await expect.poll(isConnected).toBe(true)
})
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
await stepsSlot.hover()
await stepsSlot.click({ trial: true })
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
//cancel link operation
await stepsSlot.hover()
await comfyPage.page.mouse.up()
})
await ksampler.title.hover()
const slotParent = stepsSlot.locator('../..')
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
'opacity',
'0'
)
await test.step('Connect I/O to node with snap', async () => {
const hasSnap = () =>
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
expect(await hasSnap()).toBe(false)
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()
//move hover off the slot
await ksampler.title.hover()
})
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
'opacity',
'0'
)
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()

View File

@@ -1082,10 +1082,17 @@ test.describe(
comfyPage,
comfyMouse
}) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
// Setup workflow with a KSampler node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nodeOps.waitForGraphNodes(0)
await comfyPage.searchBoxV2.addNode('KSampler')
await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await comfyPage.nodeOps.waitForGraphNodes(1)
// Convert the KSampler node to a subgraph

View File

@@ -19,19 +19,3 @@ test('Can display a slot mismatched from widget type', async ({
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
})
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await comfyPage.searchBoxV2.addNode('Switch', {
position: { x: 600, y: 200 }
})
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
})

View File

@@ -9,6 +9,8 @@ const file1 = 'workflow.mp4' as const
const file2 = 'workflow.webm' as const
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
@@ -16,7 +18,9 @@ test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Video')
await comfyPage.page.mouse.dblclick(500, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})

View File

@@ -5,6 +5,8 @@ import {
} from '@e2e/fixtures/ComfyPage'
test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const loadAudioNode = comfyPage.vueNodes.getNodeByTitle('Load Audio')
const audioPreview = new AudioPreview(loadAudioNode)
@@ -12,7 +14,9 @@ test('@vue-nodes Audio Widget', async ({ comfyPage, comfyFiles }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Audio')
//await comfyPage.canvasOps.doubleClick()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Audio')
await expect(loadAudioNode).toBeVisible()
})

View File

@@ -52,4 +52,42 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
})
await expect(loadCheckpointNode).toHaveCount(3)
})
test('Can load dynamic combos', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add node with dynamic combo', async () => {
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Resize Image/Mask')
await expect(comfyPage.searchBox.input).toBeHidden()
})
const widget = comfyPage.vueNodes.getWidgetByName(
'Resize Image/Mask',
'resize_type'
)
await test.step('Update value of the dynamic combo widget', async () => {
await widget.click()
await comfyPage.page.getByRole('searchbox').fill('scale width')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(widget.locator('input')).toBeHidden()
await expect(widget).toHaveText('scale width')
})
await test.step('Swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(widget).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(widget).toBeVisible()
})
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
})

View File

@@ -4,103 +4,6 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
const generateUniqueFilename = () =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const waitForWorkflowTabState = async (comfyPage: ComfyPage, minPaths = 2) => {
await comfyPage.page.waitForFunction((expectedMinPaths) => {
let hasActivePath = false
let hasOpenPaths = false
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.ActivePath:')) {
hasActivePath = true
}
if (!key?.startsWith('Comfy.Workflow.OpenPaths:')) {
continue
}
const raw = window.sessionStorage.getItem(key)
if (!raw) continue
try {
const state = JSON.parse(raw) as { paths?: unknown[] }
hasOpenPaths =
Array.isArray(state.paths) && state.paths.length >= expectedMinPaths
if (hasActivePath && hasOpenPaths) return true
} catch {
return false
}
}
return hasActivePath && hasOpenPaths
}, minPaths)
}
type NodeRef = NonNullable<
Awaited<ReturnType<ComfyPage['nodeOps']['getFirstNodeRef']>>
>
const getRequiredFirstNodeRef = async (
comfyPage: ComfyPage,
message: string
): Promise<NodeRef> => {
const node = await comfyPage.nodeOps.getFirstNodeRef()
expect(node, message).toBeDefined()
if (!node) throw new Error(message)
return node
}
const makeActivePathStale = async (
comfyPage: ComfyPage,
staleWorkflowName: string,
activeWorkflowName: string
) => {
// Intentionally desync ActivePath from OpenPaths to exercise stale pointer recovery.
await comfyPage.page.evaluate(
([staleName, activeName]) => {
const findStorageKey = (prefix: string) => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith(prefix)) return key
}
throw new Error(`Missing ${prefix} persistence key`)
}
const activePathKey = findStorageKey('Comfy.Workflow.ActivePath:')
const openPathsKey = findStorageKey('Comfy.Workflow.OpenPaths:')
const activePointer = JSON.parse(
window.sessionStorage.getItem(activePathKey)!
) as { path: string }
const openPointer = JSON.parse(
window.sessionStorage.getItem(openPathsKey)!
) as { paths: string[]; activeIndex: number }
const pathForName = (name: string) => {
const path = openPointer.paths.find((candidate) =>
candidate.endsWith(`${name}.json`)
)
if (!path) throw new Error(`Missing stored path for ${name}`)
return path
}
const stalePath = pathForName(staleName)
const activePath = pathForName(activeName)
activePointer.path = stalePath
openPointer.paths = [stalePath, activePath]
openPointer.activeIndex = 1
window.sessionStorage.setItem(
activePathKey,
JSON.stringify(activePointer)
)
window.sessionStorage.setItem(openPathsKey, JSON.stringify(openPointer))
},
[staleWorkflowName, activeWorkflowName]
)
}
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
@@ -200,11 +103,9 @@ test.describe('Workflow Persistence', () => {
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading the default workflow'
)
const nodeId = String(firstNode.id)
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = String(firstNode!.id)
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
@@ -481,59 +382,6 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeCountB)
})
test('Restores saved workflow drafts from inactive restored tabs', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const workflowA = generateUniqueFilename()
const workflowB = generateUniqueFilename()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await fitToViewInstant(comfyPage)
await comfyPage.menu.topbar.saveWorkflow(workflowA)
const firstNode = await getRequiredFirstNodeRef(
comfyPage,
'First node should be available after loading single_ksampler'
)
await firstNode.centerOnNode()
const draftSaveStartedAt = Date.now()
await firstNode.toggleCollapse()
expect(await firstNode.isCollapsed()).toBe(true)
await comfyPage.workflow.waitForDraftIndexUpdatedSince(draftSaveStartedAt)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
await waitForWorkflowTabState(comfyPage)
await makeActivePathStale(comfyPage, workflowA, workflowB)
await comfyPage.workflow.reloadAndWaitForApp()
await expect
.poll(() => comfyPage.menu.topbar.getActiveTabName())
.toBe(workflowB)
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
await comfyPage.menu.topbar.getWorkflowTab(workflowA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const restoredNode = await getRequiredFirstNodeRef(
comfyPage,
'Restored node should be available after switching back to workflow A'
)
expect(await restoredNode.isCollapsed()).toBe(true)
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
})
test('Closing an inactive tab with save preserves its own content', async ({
comfyPage
}) => {

View File

@@ -4,7 +4,7 @@ Date: 2025-08-25
## Status
Accepted (Nx tooling choice superseded by [ADR-0010](0010-remove-nx-orchestration.md))
Proposed
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
@@ -31,8 +31,6 @@ For more information on Monorepos, check out [monorepo.tools](https://monorepo.t
For monorepo management, I'd probably go with [Nx](https://nx.dev/), but I could be conviced otherwise.
There's a [whole list here](https://monorepo.tools/#tools-review) if you're interested.
> **Update:** The Nx tooling choice has since been reversed. See [ADR-0010: Remove Nx Orchestration](0010-remove-nx-orchestration.md) for the migration to direct pnpm workspace scripts and native tool CLIs.
## Consequences
### Positive

View File

@@ -1,92 +0,0 @@
# 10. Remove Nx Orchestration
Date: 2026-05-19
## Status
Accepted
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
[ADR-0002](0002-monorepo-conversion.md) adopted [Nx](https://nx.dev/) as a tooling option for managing the
ComfyUI Frontend monorepo on top of pnpm workspaces. Nx was introduced as task
orchestration to coordinate builds, tests, lints, and types across the apps and
packages workspaces.
In practice, Nx provided little value beyond what pnpm workspaces and the
underlying native tool CLIs (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
TypeScript) already offer:
- pnpm's `--filter` and `--recursive` flags already provide topological,
parallel, and selective execution across workspaces.
- Each underlying tool already has fast, well-supported caching (Vite, Vitest,
ESLint, oxlint, TS incremental builds, etc.).
- Nx added an extra configuration surface (`nx.json`, `.nxignore`, per-package
`nx` blocks), an extra cache layer, an extra `node_modules/.cache/nx`
artifact, and an extra CI dimension to debug.
- Contributors and AI agents had to learn the Nx mental model in addition to
pnpm and the individual tool CLIs.
- The Nx daemon and remote-cache features were not in use, so the runtime
benefit was limited to local task graph caching, which is largely redundant
with the per-tool caches.
The cost (configuration, mental overhead, surprise behavior, occasional
cache-related failures) exceeded the benefit.
## Decision
Remove Nx from the repository and run monorepo tasks using:
- pnpm workspace scripts (`pnpm -r run <script>`,
`pnpm --filter <pkg> run <script>`).
- Each tool's native CLI (Vite, Vitest, Playwright, ESLint, oxlint, oxfmt,
`vue-tsc`, etc.) invoked directly from the relevant workspace.
Concretely, this change:
- Deletes `nx.json` and `.nxignore`.
- Removes `nx` entries from root and per-package `package.json` files (the
`nx` block on each `package.json`, the dev dependency, and Nx-specific
scripts).
- Removes `nx`-related entries from `pnpm-workspace.yaml`'s `allowBuilds`.
- Rewrites the affected CI workflows (`.github/workflows/ci-tests-e2e.yaml`,
`.github/workflows/release-draft-create.yaml`) to call pnpm/native CLIs
directly.
- Updates `AGENTS.md`, `TROUBLESHOOTING.md`, and
[ADR-0002](0002-monorepo-conversion.md) to reflect the new tooling story.
- Cleans up Nx-specific lint/format/ignore rules in `.oxlintrc.json`,
`eslint.config.ts`, `vite.config.mts`, and `.gitignore`.
## Consequences
### Positive
- Fewer moving parts: no `nx.json`, no `.nx/` cache, no Nx daemon, no
Nx-specific scripts to maintain.
- Easier onboarding for contributors and AI agents: pnpm + each tool's CLI is
the only required knowledge.
- CI logs and failures are easier to read because tasks run directly under the
tool that owns them, instead of being wrapped by Nx.
- Faster, more predictable cache invalidation behavior — each tool owns its
own cache and we no longer hit Nx-cache edge cases.
- Smaller dependency tree (~2k fewer lines in `pnpm-lock.yaml`).
### Negative
- We lose Nx's unified task graph and project graph commands; coordination
across workspaces now relies on pnpm filters and explicit script wiring.
- We lose Nx's remote/distributed caching as a future option without
re-adopting Nx (or a comparable tool like Turborepo).
- Contributors who already knew Nx workflows need to relearn the equivalent
pnpm invocations.
## Notes
- The migration is purely a tooling change; no application behavior, public
API, or build output changes.
- If we later need more sophisticated task orchestration (e.g. distributed
remote cache, fine-grained affected-graph queries), revisit this decision and
evaluate Nx, Turborepo, or Moon at that time, with concrete CI/perf data to
justify the additional complexity.

View File

@@ -8,18 +8,16 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| ----------------------------------------------------------- | ------------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
## Creating a New ADR

View File

@@ -76,6 +76,7 @@ export default defineConfig([
{
ignores: [
'.i18nrc.cjs',
'.nx/*',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'components.d.ts',

41
nx.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
},
{
"plugin": "@nx/storybook/plugin",
"options": {
"serveStorybookTargetName": "storybook",
"buildStorybookTargetName": "build-storybook",
"testStorybookTargetName": "test-storybook",
"staticStorybookTargetName": "static-storybook"
}
},
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"testTargetName": "test",
"serveTargetName": "serve",
"devTargetName": "dev",
"previewTargetName": "preview",
"serveStaticTargetName": "serve-static",
"typecheckTargetName": "typecheck",
"buildDepsTargetName": "build-deps",
"watchDepsTargetName": "watch-deps"
}
},
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
}
}
],
"analytics": false
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.13",
"version": "1.45.9",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -8,22 +8,20 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
@@ -36,32 +34,32 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src browser_tests --type-aware && eslint src --cache",
"lint:desktop": "pnpm --filter @comfyorg/desktop-ui run lint",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src browser_tests --type-aware",
"prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "vite preview --config vite.config.mts",
"storybook": "storybook dev -p 6006",
"storybook:desktop": "pnpm --filter @comfyorg/desktop-ui run storybook",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec playwright test",
"test:browser": "pnpm exec nx e2e",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
"typecheck:desktop": "pnpm --filter @comfyorg/desktop-ui run typecheck",
"typecheck:website": "pnpm --filter @comfyorg/website run typecheck",
"zipdist": "node scripts/zipdist.js"
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"typecheck:website": "nx run @comfyorg/website:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
},
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/fbx-exporter-three": "^1.0.1",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
@@ -114,7 +112,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "catalog:",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
@@ -132,6 +130,10 @@
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",
"@nx/eslint": "catalog:",
"@nx/playwright": "catalog:",
"@nx/storybook": "catalog:",
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -177,6 +179,7 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"monocart-coverage-reports": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
@@ -207,7 +210,20 @@
},
"engines": {
"node": "24.x",
"pnpm": ">=11"
"pnpm": ">=10"
},
"packageManager": "pnpm@11.1.1"
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"vite": "catalog:"
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js",
"protobufjs",
"sharp",
"unrs-resolver",
"vue-demi"
]
}
}

View File

@@ -19,5 +19,12 @@
"devDependencies": {
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:design"
]
}
}

View File

@@ -15,5 +15,12 @@
},
"devDependencies": {
"@hey-api/openapi-ts": "0.93.0"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -356,7 +356,10 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -386,21 +389,8 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -437,11 +427,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -457,7 +447,6 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -653,17 +642,6 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -685,21 +663,6 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -836,11 +799,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,

View File

@@ -1382,250 +1382,6 @@ export type JwkKey = {
y: string
}
/**
* RFC 6749 §5.2 error response.
*/
export type OAuthTokenError = {
/**
* RFC 6749 §5.2 error code: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope.
*/
error: string
/**
* Human-readable, no leak of internal storage state.
*/
error_description?: string
}
/**
* RFC 6749 §5.1 successful token response.
*/
export type OAuthTokenResponse = {
/**
* Resource-bound Cloud JWT (audience matches the protected resource).
*/
access_token: string
token_type: 'Bearer'
/**
* Access token lifetime in seconds.
*/
expires_in: number
/**
* Opaque refresh token. Rotates on every successful refresh; presenting an already-rotated token revokes the entire family.
*/
refresh_token: string
/**
* Space-delimited scopes granted with this token.
*/
scope: string
}
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export type OAuthConsentChallengeWorkspace = {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export type OAuthAuthorizeRedirectResponse = {
/**
* OAuth client redirect URI with either code+state for allow, or error+state for deny.
*/
redirect_url: string
}
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export type OAuthConsentChallenge = {
/**
* Opaque server-side identifier for the authorization-request row. Carried back unchanged in the consent submission.
*/
oauth_request_id: string
/**
* Per-row CSRF token bound to this authorization request (not to the session). Must be echoed back on POST.
*/
csrf_token: string
/**
* Human-readable name of the OAuth client requesting authorization, from oauth_clients.display_name.
*/
client_display_name: string
/**
* Human-readable name of the protected resource, from oauth_resources.display_name.
*/
resource_display_name: string
/**
* Scopes the client is requesting for this resource. The frontend should present these for the user to approve.
*/
scopes: Array<string>
/**
* Workspaces the user can select from. Membership is re-checked on POST.
*/
workspaces: Array<OAuthConsentChallengeWorkspace>
}
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export type OAuthProtectedResourceMetadata = {
resource: string
authorization_servers: Array<string>
scopes_supported: Array<string>
bearer_methods_supported?: Array<string>
}
/**
* RFC 7591 §3.2.2 error response.
*/
export type OAuthRegisterError = {
error: 'invalid_redirect_uri' | 'invalid_client_metadata'
error_description?: string | null
}
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs.
*
*/
export type OAuthRegisterBadRequestResponse =
| OAuthRegisterError
| BindingErrorResponse
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export type BindingErrorResponse = {
message: string
}
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export type OAuthRegisterResponse = {
/**
* Server-generated client_id. Always carries the `comfy-dyn-` prefix.
*/
client_id: string
/**
* Unix timestamp (seconds) when the client was registered.
*/
client_id_issued_at: number
client_name?: string
redirect_uris: Array<string>
grant_types: Array<string>
response_types: Array<string>
token_endpoint_auth_method: 'none'
application_type: 'native' | 'web'
}
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export type OAuthRegisterRequest = {
/**
* 15 redirect URIs. Validated against `application_type` policy.
*/
redirect_uris: Array<string>
/**
* Human-readable name shown in the consent UI. Reserved-name list rejects impersonation of major MCP clients.
*/
client_name?: string
/**
* RFC 7591 §2 application_type. **REQUIRED** — clients MUST declare intent; the server does not default this field. `native` for desktop / CLI / MCP-spec-strict clients (loopback redirects); `web` for hosted clients (HTTPS only, host must be allowlisted). A missing or explicitly empty `application_type` rejects with `invalid_client_metadata`. The realistic MCP-client population is overwhelmingly native/loopback — requiring explicit declaration avoids silently bouncing those clients off the web HTTPS policy.
*
*/
application_type: 'native' | 'web'
/**
* Public clients only this phase — must be `none` if present. The server forces `none` regardless.
*/
token_endpoint_auth_method?: 'none'
/**
* Optional. Defaults to `["authorization_code","refresh_token"]`.
*/
grant_types?: Array<'authorization_code' | 'refresh_token'>
/**
* Optional. Defaults to `["code"]`.
*/
response_types?: Array<'code'>
/**
* **REJECTED IF PRESENT.** Dynamic clients do not pick scopes — the server assigns scopes from the active MCP resource's published list. Sending `scope` in the registration body is treated as a privilege-escalation attempt and returns `invalid_client_metadata`. The field is documented here so clients see a well-defined error rather than silent drop.
*
*/
scope?: string | null
/**
* **REJECTED IF PRESENT.** Same reason as `scope`. The set of resources and scopes a dynamic client may request is server-policy, not request-driven.
*
*/
resource_grants?: {
[key: string]: Array<string>
} | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
client_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
logo_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
tos_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
policy_uri?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
software_id?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
software_version?: string | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
contacts?: Array<string> | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
jwks?: {
[key: string]: unknown
} | null
/**
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
*/
jwks_uri?: string | null
}
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export type OAuthAuthorizationServerMetadata = {
issuer: string
authorization_endpoint: string
token_endpoint: string
jwks_uri: string
/**
* RFC 7591 §3.1 Dynamic Client Registration endpoint. Advertised so MCP-spec-compliant clients can auto-discover and self-register without operator involvement. Present only when DCR is enabled.
*
*/
registration_endpoint?: string
response_types_supported: Array<string>
grant_types_supported: Array<string>
code_challenge_methods_supported: Array<string>
token_endpoint_auth_methods_supported: Array<string>
scopes_supported?: Array<string>
}
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -1775,10 +1531,6 @@ export type WorkspaceApiKeyInfo = {
* User-provided label
*/
name: string
/**
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
*/
description: string
/**
* First 8 chars after prefix for display
*/
@@ -1813,10 +1565,6 @@ export type CreateWorkspaceApiKeyResponse = {
* User-provided label
*/
name: string
/**
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
*/
description: string
/**
* The full plaintext API key (only shown once)
*/
@@ -1843,10 +1591,6 @@ export type CreateWorkspaceApiKeyRequest = {
* User-provided label for the key
*/
name: string
/**
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
*/
description?: string
/**
* Optional expiration timestamp
*/
@@ -2526,12 +2270,6 @@ export type ListAssetsResponse = {
* Whether more assets are available beyond this page
*/
has_more: boolean
/**
* Opaque cursor to pass as the `after` query parameter to fetch the
* next page. Omitted from the response when there are no more results.
*
*/
next_cursor?: string
}
/**
@@ -2546,10 +2284,6 @@ export type Asset = {
* Name of the asset file
*/
name: string
/**
* Display name of the asset. Mirrors name for backwards compatibility.
*/
display_name?: string | null
/**
* Blake3 hash of the asset content
*/
@@ -2626,10 +2360,6 @@ export type AssetUpdated = {
* Updated name of the asset
*/
name?: string
/**
* Display name of the asset. Mirrors name for backwards compatibility.
*/
display_name?: string | null
/**
* Blake3 hash of the asset content
*/
@@ -3305,6 +3035,13 @@ export type ExportDownloadUrlResponse = {
expires_at?: string
}
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export type BindingErrorResponse = {
message: string
}
/**
* Standard error response with a machine-readable code and human-readable message.
*/
@@ -3387,12 +3124,6 @@ export type ListAssetsResponseWritable = {
* Whether more assets are available beyond this page
*/
has_more: boolean
/**
* Opaque cursor to pass as the `after` query parameter to fetch the
* next page. Omitted from the response when there are no more results.
*
*/
next_cursor?: string
}
/**
@@ -3407,10 +3138,6 @@ export type AssetWritable = {
* Name of the asset file
*/
name: string
/**
* Display name of the asset. Mirrors name for backwards compatibility.
*/
display_name?: string | null
/**
* Blake3 hash of the asset content
*/
@@ -3780,6 +3507,50 @@ export type GetModelsInFolderResponses = {
export type GetModelsInFolderResponse =
GetModelsInFolderResponses[keyof GetModelsInFolderResponses]
export type GetModelPreviewData = {
body?: never
path: {
/**
* The folder name containing the model
*/
folder: string
/**
* The path index (usually 0 for cloud service)
*/
path_index: number
/**
* The model filename (with or without .webp extension)
*/
filename: string
}
query?: never
url: '/api/experiment/models/preview/{folder}/{path_index}/{filename}'
}
export type GetModelPreviewErrors = {
/**
* Model not found or preview not available
*/
404: ErrorResponse
/**
* Internal server error
*/
500: ErrorResponse
}
export type GetModelPreviewError =
GetModelPreviewErrors[keyof GetModelPreviewErrors]
export type GetModelPreviewResponses = {
/**
* Success - Model preview image
*/
200: Blob | File
}
export type GetModelPreviewResponse =
GetModelPreviewResponses[keyof GetModelPreviewResponses]
export type GetLegacyHistoryData = {
body?: never
path?: never
@@ -4241,6 +4012,10 @@ export type ListAssetsData = {
* Sort order
*/
order?: 'asc' | 'desc'
/**
* Filter assets by job IDs (prompt IDs)
*/
job_ids?: Array<string>
/**
* Whether to include public/shared assets in results
*/
@@ -4249,17 +4024,6 @@ export type ListAssetsData = {
* Filter assets by exact content hash
*/
asset_hash?: string
/**
* Opaque cursor for keyset pagination. Pass the `next_cursor` value
* from the previous response to fetch the next page. When provided,
* `offset` is ignored. Cursor pagination is only supported with
* `sort` values `created_at`, `updated_at`, `name`, or `size`;
* requests combining `after` with other sort fields return 400.
* The cursor must have been minted under the same `sort` value used
* in the follow-up request.
*
*/
after?: string
}
url: '/api/assets'
}
@@ -4358,6 +4122,10 @@ export type UploadAssetErrors = {
export type UploadAssetError = UploadAssetErrors[keyof UploadAssetErrors]
export type UploadAssetResponses = {
/**
* Asset already exists (returned existing asset)
*/
200: AssetCreated
/**
* Asset created successfully
*/
@@ -4420,6 +4188,10 @@ export type CreateAssetFromHashError =
CreateAssetFromHashErrors[keyof CreateAssetFromHashErrors]
export type CreateAssetFromHashResponses = {
/**
* Asset reference already exists (returned existing)
*/
200: AssetCreated
/**
* Asset reference created successfully
*/
@@ -5573,19 +5345,19 @@ export type UpdateMultipleSettingsResponses = {
export type UpdateMultipleSettingsResponse =
UpdateMultipleSettingsResponses[keyof UpdateMultipleSettingsResponses]
export type GetSettingByIdData = {
export type GetSettingByKeyData = {
body?: never
path: {
/**
* Setting id to retrieve
* Setting key to retrieve
*/
id: string
key: string
}
query?: never
url: '/api/settings/{id}'
url: '/api/settings/{key}'
}
export type GetSettingByIdErrors = {
export type GetSettingByKeyErrors = {
/**
* Unauthorized
*/
@@ -5596,10 +5368,10 @@ export type GetSettingByIdErrors = {
404: ErrorResponse
}
export type GetSettingByIdError =
GetSettingByIdErrors[keyof GetSettingByIdErrors]
export type GetSettingByKeyError =
GetSettingByKeyErrors[keyof GetSettingByKeyErrors]
export type GetSettingByIdResponses = {
export type GetSettingByKeyResponses = {
/**
* Setting value response
*/
@@ -5611,25 +5383,25 @@ export type GetSettingByIdResponses = {
}
}
export type GetSettingByIdResponse =
GetSettingByIdResponses[keyof GetSettingByIdResponses]
export type GetSettingByKeyResponse =
GetSettingByKeyResponses[keyof GetSettingByKeyResponses]
export type UpdateSettingByIdData = {
export type UpdateSettingByKeyData = {
/**
* New value for the setting
*/
body: unknown
path: {
/**
* Setting id to update
* Setting key to update
*/
id: string
key: string
}
query?: never
url: '/api/settings/{id}'
url: '/api/settings/{key}'
}
export type UpdateSettingByIdErrors = {
export type UpdateSettingByKeyErrors = {
/**
* Invalid request
*/
@@ -5640,10 +5412,10 @@ export type UpdateSettingByIdErrors = {
401: ErrorResponse
}
export type UpdateSettingByIdError =
UpdateSettingByIdErrors[keyof UpdateSettingByIdErrors]
export type UpdateSettingByKeyError =
UpdateSettingByKeyErrors[keyof UpdateSettingByKeyErrors]
export type UpdateSettingByIdResponses = {
export type UpdateSettingByKeyResponses = {
/**
* Updated setting value response
*/
@@ -5655,8 +5427,8 @@ export type UpdateSettingByIdResponses = {
}
}
export type UpdateSettingByIdResponse =
UpdateSettingByIdResponses[keyof UpdateSettingByIdResponses]
export type UpdateSettingByKeyResponse =
UpdateSettingByKeyResponses[keyof UpdateSettingByKeyResponses]
export type SubmitFeedbackData = {
body: FeedbackRequest
@@ -6144,6 +5916,40 @@ export type UploadMaskResponses = {
* Type of upload (e.g., "output")
*/
type?: string
/**
* Additional metadata for mask detection and re-editing
*/
metadata?: {
/**
* Whether this file is a mask
*/
is_mask?: boolean
/**
* Hash of the original unmasked image
*/
original_hash?: string
/**
* Type of mask (e.g., "painted_masked")
*/
mask_type?: string
/**
* Related mask layer files (if available)
*/
related_files?: {
/**
* Hash of the mask layer
*/
mask?: string
/**
* Hash of the paint layer
*/
paint?: string
/**
* Hash of the painted image
*/
painted?: string
}
}
}
}
@@ -6311,229 +6117,6 @@ export type GetJwksResponses = {
export type GetJwksResponse = GetJwksResponses[keyof GetJwksResponses]
export type GetOAuthAuthorizationServerData = {
body?: never
path?: never
query?: never
url: '/.well-known/oauth-authorization-server'
}
export type GetOAuthAuthorizationServerErrors = {
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type GetOAuthAuthorizationServerError =
GetOAuthAuthorizationServerErrors[keyof GetOAuthAuthorizationServerErrors]
export type GetOAuthAuthorizationServerResponses = {
/**
* Authorization-server metadata
*/
200: OAuthAuthorizationServerMetadata
}
export type GetOAuthAuthorizationServerResponse =
GetOAuthAuthorizationServerResponses[keyof GetOAuthAuthorizationServerResponses]
export type GetOAuthProtectedResourceData = {
body?: never
path?: never
query?: never
url: '/.well-known/oauth-protected-resource'
}
export type GetOAuthProtectedResourceErrors = {
/**
* OAuth disabled or no active resource configured
*/
404: ErrorResponse
}
export type GetOAuthProtectedResourceError =
GetOAuthProtectedResourceErrors[keyof GetOAuthProtectedResourceErrors]
export type GetOAuthProtectedResourceResponses = {
/**
* Protected-resource metadata
*/
200: OAuthProtectedResourceMetadata
}
export type GetOAuthProtectedResourceResponse =
GetOAuthProtectedResourceResponses[keyof GetOAuthProtectedResourceResponses]
export type GetOAuthAuthorizeData = {
body?: never
path?: never
query?: {
response_type?: string
client_id?: string
redirect_uri?: string
scope?: string
/**
* RFC 6749 §10.12 marks `state` as RECOMMENDED. Our hardening makes
* it REQUIRED on the initial-entry path (omitted only on the resume
* path where `oauth_request_id` is supplied instead). This parameter
* is `required: false` at the spec level only because the operation
* is dual-mode (initial entry vs. resume); the runtime parser
* (services/ingest/server/implementation/oauth/protocol/request.go)
* rejects empty `state` on the initial-entry path with a stable
* `invalid_request` 400.
*
*/
state?: string
code_challenge?: string
code_challenge_method?: string
resource?: string
oauth_request_id?: string
}
url: '/oauth/authorize'
}
export type GetOAuthAuthorizeErrors = {
/**
* Invalid authorize request (pre-redirect failure — unknown client, redirect mismatch, malformed params)
*/
400: ErrorResponse
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type GetOAuthAuthorizeError =
GetOAuthAuthorizeErrors[keyof GetOAuthAuthorizeErrors]
export type GetOAuthAuthorizeResponses = {
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
200: OAuthConsentChallenge
}
export type GetOAuthAuthorizeResponse =
GetOAuthAuthorizeResponses[keyof GetOAuthAuthorizeResponses]
export type PostOAuthAuthorizeData = {
body: {
oauth_request_id: string
csrf_token: string
decision: 'allow' | 'deny'
workspace_id: string
}
path?: never
query?: never
url: '/oauth/authorize'
}
export type PostOAuthAuthorizeErrors = {
/**
* Bad request (CSRF mismatch, expired/consumed request, inaccessible workspace)
*/
400: ErrorResponse
/**
* Scope broadening on consent re-grant — fresh consent flow required
*/
403: ErrorResponse
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type PostOAuthAuthorizeError =
PostOAuthAuthorizeErrors[keyof PostOAuthAuthorizeErrors]
export type PostOAuthAuthorizeResponses = {
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
200: OAuthAuthorizeRedirectResponse
}
export type PostOAuthAuthorizeResponse =
PostOAuthAuthorizeResponses[keyof PostOAuthAuthorizeResponses]
export type PostOAuthTokenData = {
body: {
grant_type: 'authorization_code' | 'refresh_token'
client_id: string
code?: string
redirect_uri?: string
code_verifier?: string
refresh_token?: string
scope?: string
client_secret?: string
}
path?: never
query?: never
url: '/oauth/token'
}
export type PostOAuthTokenErrors = {
/**
* RFC 6749 §5.2 error
*/
400: OAuthTokenError
/**
* OAuth disabled
*/
404: ErrorResponse
}
export type PostOAuthTokenError =
PostOAuthTokenErrors[keyof PostOAuthTokenErrors]
export type PostOAuthTokenResponses = {
/**
* New token pair
*/
200: OAuthTokenResponse
}
export type PostOAuthTokenResponse =
PostOAuthTokenResponses[keyof PostOAuthTokenResponses]
export type PostOAuthRegisterData = {
body: OAuthRegisterRequest
path?: never
query?: never
url: '/oauth/register'
}
export type PostOAuthRegisterErrors = {
/**
* Bad request. Two shapes possible: `OAuthRegisterError` (RFC 7591 §3.2.2, emitted by the handler for invalid client metadata, missing application_type, reserved client_name, etc.) OR `BindingErrorResponse` (emitted by the strict-server binding layer when the request body fails OpenAPI-schema validation — malformed JSON, missing required fields, `additionalProperties: false` violations).
*
*/
400: OAuthRegisterBadRequestResponse
/**
* OAuth disabled
*/
404: ErrorResponse
/**
* No active MCP resource is configured — DCR cannot mint a usable client until ops seeds an active oauth_resources row.
*/
503: ErrorResponse
}
export type PostOAuthRegisterError =
PostOAuthRegisterErrors[keyof PostOAuthRegisterErrors]
export type PostOAuthRegisterResponses = {
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
201: OAuthRegisterResponse
}
export type PostOAuthRegisterResponse =
PostOAuthRegisterResponses[keyof PostOAuthRegisterResponses]
export type ListWorkspacesData = {
body?: never
path?: never
@@ -7096,7 +6679,7 @@ export type CreateWorkspaceApiKeyErrors = {
*/
401: ErrorResponse
/**
* Not a workspace member
* Not a workspace member or personal workspace
*/
403: ErrorResponse
/**
@@ -9305,20 +8888,9 @@ export type GetTemplateProxyData = {
export type GetTemplateProxyErrors = {
/**
* Template not found.
* Template not found
*/
404: unknown
/**
* Workflow templates version not available.
*/
503: unknown
}
export type GetTemplateProxyResponses = {
/**
* Template file content streamed from GCS.
*/
200: unknown
}
export type GetHealthData = {
@@ -9346,6 +8918,20 @@ export type GetHealthResponses = {
export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]
export type GetOpenapiSpecData = {
body?: never
path?: never
query?: never
url: '/openapi'
}
export type GetOpenapiSpecResponses = {
/**
* OpenAPI specification document
*/
200: unknown
}
export type GetMonitoringTasksData = {
body?: never
path?: never
@@ -9608,33 +9194,6 @@ export type PostCustomNodeProxyResponses = {
200: unknown
}
export type GetModelPreviewData = {
body?: never
path: {
/**
* The folder name containing the model.
*/
folder: string
/**
* The path index (usually 0 for cloud service).
*/
path_index: number
/**
* The model filename (with or without .webp extension).
*/
filename: string
}
query?: never
url: '/api/experiment/models/preview/{folder}/{path_index}/{filename}'
}
export type GetModelPreviewErrors = {
/**
* Preview not available on Cloud
*/
404: unknown
}
export type GetLegacyPromptByIdData = {
body?: never
path: {

View File

@@ -879,153 +879,6 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs.
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zBindingErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -1087,7 +940,6 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -1108,7 +960,6 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -1120,7 +971,6 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1503,7 +1353,6 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
display_name: z.string().nullish(),
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
@@ -1536,8 +1385,7 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean(),
next_cursor: z.string().optional()
has_more: z.boolean()
})
/**
@@ -1546,7 +1394,6 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
display_name: z.string().nullish(),
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
@@ -1906,6 +1753,13 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
@@ -1942,7 +1796,6 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
display_name: z.string().nullish(),
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
@@ -1974,8 +1827,7 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean(),
next_cursor: z.string().optional()
has_more: z.boolean()
})
/**
@@ -2109,6 +1961,21 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2265,9 +2132,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional(),
after: z.string().optional()
asset_hash: z.string().optional()
})
.optional()
})
@@ -2290,7 +2157,7 @@ export const zUploadAssetData = z.object({
})
/**
* Asset created successfully
* Asset already exists (returned existing asset)
*/
export const zUploadAssetResponse = zAssetCreated
@@ -2307,7 +2174,7 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference created successfully
* Asset reference already exists (returned existing)
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2642,10 +2509,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByIdData = z.object({
export const zGetSettingByKeyData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
key: z.string()
}),
query: z.never().optional()
})
@@ -2653,14 +2520,14 @@ export const zGetSettingByIdData = z.object({
/**
* Setting value response
*/
export const zGetSettingByIdResponse = z.object({
export const zGetSettingByKeyResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByIdData = z.object({
export const zUpdateSettingByKeyData = z.object({
body: z.unknown(),
path: z.object({
id: z.string()
key: z.string()
}),
query: z.never().optional()
})
@@ -2668,7 +2535,7 @@ export const zUpdateSettingByIdData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByIdResponse = z.object({
export const zUpdateSettingByKeyResponse = z.object({
value: z.unknown().optional()
})
@@ -2824,7 +2691,21 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional()
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
})
export const zGetLogsData = z.object({
@@ -2893,101 +2774,6 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3885,6 +3671,12 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3965,16 +3757,6 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -20,5 +20,11 @@
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

View File

@@ -5,5 +5,12 @@
"type": "module",
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"packageManager": "pnpm@10.17.1",
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

View File

@@ -18,5 +18,6 @@
},
"devDependencies": {
"typescript": "catalog:"
}
},
"packageManager": "pnpm@10.17.1"
}

Some files were not shown because too many files have changed in this diff Show More