Compare commits
45 Commits
drjkl/worl
...
ecs-vue-ho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6fe052cd | ||
|
|
83263fd2ee | ||
|
|
581c5bb38e | ||
|
|
4d37194be6 | ||
|
|
5bb54de0d7 | ||
|
|
68843967cf | ||
|
|
8c295e7c68 | ||
|
|
219a574eed | ||
|
|
fef2cab31e | ||
|
|
20ee262f78 | ||
|
|
6a8c453659 | ||
|
|
ea277dec4d | ||
|
|
a7aa124c10 | ||
|
|
9c62bbc74a | ||
|
|
f0e16cdf46 | ||
|
|
0658c1ac9c | ||
|
|
997501d8fb | ||
|
|
ab6e5ba094 | ||
|
|
2322a5a497 | ||
|
|
0bc951fd12 | ||
|
|
0446ca7a18 | ||
|
|
653ee48444 | ||
|
|
81d9df61f2 | ||
|
|
f4358cb161 | ||
|
|
5948002dee | ||
|
|
1ab9752af8 | ||
|
|
e469611f6d | ||
|
|
ad6cbf7cbe | ||
|
|
5ebf5e03ae | ||
|
|
d3ab2be695 | ||
|
|
37f0fbcbef | ||
|
|
6ef051f200 | ||
|
|
0788e71394 | ||
|
|
d3f802de10 | ||
|
|
d78c630d36 | ||
|
|
aa4343a98b | ||
|
|
270c7e34f4 | ||
|
|
666684e6e6 | ||
|
|
4484b62854 | ||
|
|
d29169ff4e | ||
|
|
3e6f3444e5 | ||
|
|
e46667b33f | ||
|
|
d5121d3fed | ||
|
|
733917d5cf | ||
|
|
08967bc684 |
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,10 +54,14 @@ jobs:
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
# PRs run each test once to keep wall time bounded; main runs 3× so the
|
||||
# baseline saved to perf-data has enough samples to median over noise.
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
env:
|
||||
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,31 +39,33 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
@@ -114,6 +119,7 @@ jobs:
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -122,7 +128,9 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
|
||||
#
|
||||
# Runs on any PR touching extension-api declaration files, extension-api-v2
|
||||
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
|
||||
#
|
||||
# Two jobs:
|
||||
# test — vitest run against src/extension-api-v2/__tests__/
|
||||
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
|
||||
# blast_radius ≥ 2.0 category is missing a stub triple)
|
||||
#
|
||||
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
|
||||
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
|
||||
name: 'CI: Tests Extension API'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Extension API tests (vitest)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run extension-api test suite
|
||||
run: pnpm test:extension-api
|
||||
|
||||
- name: Run with coverage (push only)
|
||||
if: github.event_name == 'push'
|
||||
run: pnpm test:extension-api:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name == 'push'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: extension-api
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
compat-floor:
|
||||
name: Compat-floor gate (blast_radius ≥ 2.0)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Check compat floor
|
||||
run: python3 scripts/check-compat-floor.py
|
||||
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
|
||||
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.
|
||||
33
apps/website/e2e/customers.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customers @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers')
|
||||
})
|
||||
|
||||
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
|
||||
await expect(heroImage).toBeVisible()
|
||||
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
|
||||
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
|
||||
|
||||
// Regression guard: an unloaded <img> without intrinsic dimensions
|
||||
// collapses to ~0px, then jumps to its natural size on load and pushes
|
||||
// the video below it. Reserved space must persist before bytes arrive.
|
||||
const heightWhileUnloaded = await page.evaluate(() => {
|
||||
const img = document.querySelector<HTMLImageElement>(
|
||||
'img[alt="Comfy 3D logo"]'
|
||||
)
|
||||
if (!img) return null
|
||||
img.removeAttribute('src')
|
||||
return img.getBoundingClientRect().height
|
||||
})
|
||||
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 99 KiB |
@@ -27,6 +27,7 @@
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ScrollTrigger } from '../../scripts/gsapSetup'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -22,6 +23,10 @@ useHeroAnimation({
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
|
||||
function handleLogoLoad() {
|
||||
ScrollTrigger.refresh(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +42,10 @@ useHeroAnimation({
|
||||
<img
|
||||
src="https://media.comfy.org/website/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
width="1568"
|
||||
height="1763"
|
||||
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
|
||||
@load="handleLogoLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div class="relative flex-1">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="w-full"
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
|
||||
328
apps/website/src/composables/useHeroLogo.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
|
||||
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
const IMAGE_COUNT = 16
|
||||
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
|
||||
|
||||
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
|
||||
|
||||
interface HeroLogoConfig {
|
||||
speed: number
|
||||
tiltX: number
|
||||
tiltZ: number
|
||||
zoom: number
|
||||
fov: number
|
||||
logoColor: string
|
||||
extrudeDepth: number
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
speed: 1,
|
||||
tiltX: -0.1,
|
||||
tiltZ: -0.1,
|
||||
zoom: 7,
|
||||
fov: 50,
|
||||
logoColor: '#F2FF59',
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
|
||||
}
|
||||
|
||||
export function useHeroLogo(
|
||||
containerRef: Ref<HTMLElement | undefined>,
|
||||
config: Partial<HeroLogoConfig> = {}
|
||||
) {
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
const loaded = ref(false)
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
stencil: true,
|
||||
alpha: true
|
||||
})
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.style.position = 'absolute'
|
||||
renderer.domElement.style.inset = '0'
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
let disposed = false
|
||||
const teardowns: Array<() => void> = []
|
||||
cleanup = () => {
|
||||
disposed = true
|
||||
teardowns.forEach((fn) => fn())
|
||||
}
|
||||
teardowns.push(() => {
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
})
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
cfg.fov,
|
||||
width / height,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
// Background plane (stencil read)
|
||||
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
|
||||
const bgPlaneMat = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
map: textures[0] ?? null,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
stencilWrite: true,
|
||||
stencilFunc: THREE.EqualStencilFunc,
|
||||
stencilRef: 1,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
stencilZPass: THREE.KeepStencilOp
|
||||
})
|
||||
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
|
||||
bgPlane.renderOrder = 1
|
||||
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
|
||||
scene.add(bgPlane)
|
||||
|
||||
// Logo group
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
|
||||
const s = scaleFactor
|
||||
const depth = cfg.extrudeDepth
|
||||
|
||||
// Front face
|
||||
const shapeGeo = new THREE.ShapeGeometry(shapes)
|
||||
shapeGeo.translate(-cx, -cy, 0)
|
||||
shapeGeo.scale(s, -s, s)
|
||||
const shapeMat = new THREE.MeshBasicMaterial({
|
||||
color: cfg.logoColor,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true
|
||||
})
|
||||
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
|
||||
logoMesh.renderOrder = 2
|
||||
group.add(logoMesh)
|
||||
|
||||
// Extrusion stencil mask
|
||||
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
|
||||
depth,
|
||||
bevelEnabled: false
|
||||
})
|
||||
extrudeGeo.translate(-cx, -cy, -depth)
|
||||
extrudeGeo.scale(s, -s, s)
|
||||
const extrudeMat = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
stencilWrite: true,
|
||||
stencilRef: 1,
|
||||
stencilFunc: THREE.AlwaysStencilFunc,
|
||||
stencilZPass: THREE.ReplaceStencilOp,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
|
||||
extrudeMesh.renderOrder = 0
|
||||
group.add(extrudeMesh)
|
||||
|
||||
// Interaction
|
||||
let isDragging = false
|
||||
let previousX = 0
|
||||
let dragVelocity = 0
|
||||
let currentTiltX = 0
|
||||
let currentTiltY = 0
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let rotationT = 0
|
||||
let currentSlide = 0
|
||||
let slideTimer = 0
|
||||
let animationId = 0
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
pointerX = (e.clientX / window.innerWidth) * 2 - 1
|
||||
pointerY = (e.clientY / window.innerHeight) * 2 - 1
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
isDragging = true
|
||||
dragVelocity = 0
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return
|
||||
dragVelocity = (e.clientX - previousX) * 0.005
|
||||
rotationT += dragVelocity
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
camera.aspect = rect.width / rect.height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(rect.width, rect.height)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
|
||||
function animate() {
|
||||
if (disposed) return
|
||||
animationId = requestAnimationFrame(animate)
|
||||
const dt = clock.getDelta()
|
||||
|
||||
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
|
||||
dragVelocity *= 0.95
|
||||
rotationT += dragVelocity
|
||||
} else if (!isDragging) {
|
||||
dragVelocity = 0
|
||||
}
|
||||
|
||||
rotationT += cfg.speed * dt
|
||||
|
||||
currentTiltX += (pointerY - currentTiltX) * 0.08
|
||||
currentTiltY += (pointerX - currentTiltY) * 0.08
|
||||
|
||||
group.rotation.y = rotationT % (Math.PI * 2)
|
||||
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
|
||||
group.rotation.z = cfg.tiltZ
|
||||
|
||||
if (textures.length > 1) {
|
||||
slideTimer += dt
|
||||
if (slideTimer >= cfg.slideDuration) {
|
||||
slideTimer = 0
|
||||
currentSlide = (currentSlide + 1) % textures.length
|
||||
bgPlaneMat.map = textures[currentSlide]
|
||||
bgPlaneMat.needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
teardowns.push(
|
||||
() => cancelAnimationFrame(animationId),
|
||||
() => window.removeEventListener('mousemove', onMouseMove),
|
||||
() =>
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
|
||||
() => window.removeEventListener('pointermove', onPointerMove),
|
||||
() => window.removeEventListener('pointerup', onPointerUp),
|
||||
() => window.removeEventListener('resize', onResize),
|
||||
() => bgPlaneGeo.dispose(),
|
||||
() => bgPlaneMat.dispose(),
|
||||
() => shapeGeo.dispose(),
|
||||
() => shapeMat.dispose(),
|
||||
() => extrudeGeo.dispose(),
|
||||
() => extrudeMat.dispose(),
|
||||
() => textures.forEach((tex) => tex.dispose())
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[useHeroLogo] initialization failed:', err)
|
||||
cleanup?.()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return { loaded }
|
||||
}
|
||||
@@ -1119,6 +1119,10 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1143,6 +1147,10 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
|
||||
1449
browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
Normal file
@@ -1,284 +0,0 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [225, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [270, 108],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [270, 104],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [225, 104],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -190,6 +190,9 @@ export class ComfyPage {
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
|
||||
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
|
||||
public isVueNodes = false
|
||||
|
||||
/** Test user ID for the current context */
|
||||
get id() {
|
||||
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||
@@ -352,6 +355,12 @@ export class ComfyPage {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async idleFrames(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
}
|
||||
@@ -494,6 +503,7 @@ export const comfyPageFixture = base.extend<{
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
const isVueNodes = testInfo.tags.includes('@vue-nodes')
|
||||
comfyPage.isVueNodes = isVueNodes
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
|
||||
@@ -217,13 +217,20 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -39,10 +39,32 @@ class ComfyQueueButton {
|
||||
await this.dropdownButton.click()
|
||||
return new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
}
|
||||
|
||||
public async openOptions() {
|
||||
const options = new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
if (!(await options.menu.isVisible())) {
|
||||
await this.dropdownButton.click()
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly menu: Locator
|
||||
public readonly modeItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menu = page.getByRole('menu')
|
||||
this.modeItems = this.menu.getByRole('menuitem')
|
||||
}
|
||||
|
||||
public modeItem(name: string) {
|
||||
return this.menu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
public async selectMode(name: string) {
|
||||
await this.modeItem(name).click()
|
||||
}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ContextMenu {
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
12
browser_tests/fixtures/components/WidgetSelectDropdown.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
export class WidgetSelectDropdownFixture {
|
||||
public readonly selection: Locator
|
||||
|
||||
constructor(public readonly root: Locator) {
|
||||
this.selection = root.locator('button span span')
|
||||
}
|
||||
async selectedItem(): Promise<string> {
|
||||
return await this.selection.innerText()
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
|
||||
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly mobile: MobileAppHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
@@ -60,13 +62,16 @@ export class AppModeHelper {
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
/** The main content area where outputs are displayed*/
|
||||
public readonly centerPanel: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.mobile = new MobileAppHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
@@ -125,6 +130,7 @@ export class AppModeHelper {
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
@@ -13,6 +14,7 @@ export class DragDropHelper {
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
@@ -22,13 +24,14 @@ export class DragDropHelper {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
filePath,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
if (!fileName && !filePath && !url)
|
||||
throw new Error('Must provide fileName, filePath, or url')
|
||||
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
@@ -39,12 +42,22 @@ export class DragDropHelper {
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
if (fileName || filePath) {
|
||||
const resolvedPath = filePath ?? assetPath(fileName!)
|
||||
const displayName = fileName ?? basename(resolvedPath)
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = readFileSync(resolvedPath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileName = displayName
|
||||
evaluateParams.fileType = getMimeType(displayName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ export class DragDropHelper {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropFilePath(
|
||||
filePath: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ filePath, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
33
browser_tests/fixtures/helpers/MobileAppHelper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class MobileAppHelper {
|
||||
private readonly page: Page
|
||||
readonly contentPanel: Locator
|
||||
readonly navigation: Locator
|
||||
readonly navigationTabs: Locator
|
||||
readonly view: Locator
|
||||
readonly workflows: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.page = comfyPage.page
|
||||
this.view = this.page.getByTestId(TestIds.linear.mobile)
|
||||
this.contentPanel = this.page.getByRole('tabpanel')
|
||||
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
|
||||
this.navigationTabs = this.navigation.getByRole('tab')
|
||||
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
|
||||
}
|
||||
|
||||
async switchWorkflow(workflowName: string) {
|
||||
await this.workflows.click()
|
||||
await this.page.getByRole('menu').getByText(workflowName).click()
|
||||
}
|
||||
async navigateTab(name: 'run' | 'outputs' | 'assets') {
|
||||
await this.navigation.getByRole('tab', { name }).click()
|
||||
}
|
||||
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
|
||||
for (let i = 0; i < count; i++) await locator.tap()
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ProxyWidgetTuple,
|
||||
SerializedProxyWidgetTuple
|
||||
} from '@/core/schemas/promotionSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -366,6 +362,9 @@ export class SubgraphHelper {
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
if (this.comfyPage.isVueNodes) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
@@ -390,7 +389,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
@@ -405,18 +404,15 @@ export class SubgraphHelper {
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is ProxyWidgetTuple =>
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([sourceNodeId, serializedSourceWidgetName]) =>
|
||||
[
|
||||
sourceNodeId,
|
||||
serializedSourceWidgetName
|
||||
] satisfies SerializedProxyWidgetTuple
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -144,6 +144,14 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
mobile: 'linear-mobile',
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
|
||||
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.mp3')) return 'audio/mpeg'
|
||||
if (name.endsWith('.flac')) return 'audio/flac'
|
||||
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function metadataFixturePath(fileName: string): string {
|
||||
return `./src/scripts/metadata/__fixtures__/${fileName}`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export class VueNodeFixture {
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -23,6 +24,7 @@ export class VueNodeFixture {
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -39,6 +41,16 @@ export class VueNodeFixture {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select this node and delete it via the Delete key, waiting for the node
|
||||
* element to leave the DOM before resolving.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
await this.header.click()
|
||||
await this.header.press('Delete')
|
||||
await this.locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
154
browser_tests/tests/appMode.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
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()
|
||||
|
||||
//an app without an image input will load the workflow
|
||||
await test.step('App without an image input loads workflow', async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
||||
await expect(centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
//prep a load image
|
||||
await test.step('Add a load image node', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
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 = 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']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
|
||||
await expect(imageInput.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging an image redirects to image input', async () => {
|
||||
const initialImage = await imageInput.selectedItem()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropExternalResource({
|
||||
fileName: 'workflow.webp',
|
||||
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
|
||||
|
||||
await expect(imageInput.selection).not.toHaveText(initialImage)
|
||||
await expect(
|
||||
centerPanel,
|
||||
'A file with workflow should not open a new workflow'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging a url redirects to image input', async () => {
|
||||
const secondImage = await imageInput.selectedItem()
|
||||
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: 'og-image.png',
|
||||
type: 'input'
|
||||
})
|
||||
await expect(imageInput.selection).not.toHaveText(secondImage)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widget Interaction', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
['3', 'seed'],
|
||||
['3', 'sampler_name'],
|
||||
['6', 'text']
|
||||
])
|
||||
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
|
||||
exact: true
|
||||
})
|
||||
const { input, incrementButton, decrementButton } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seed)
|
||||
const initialValue = Number(await input.inputValue())
|
||||
|
||||
await seed.dragTo(incrementButton, { steps: 5 })
|
||||
const intermediateValue = Number(await input.inputValue())
|
||||
expect(intermediateValue).toBeGreaterThan(initialValue)
|
||||
|
||||
await seed.dragTo(decrementButton, { steps: 5 })
|
||||
const endValue = Number(await input.inputValue())
|
||||
expect(endValue).toBeLessThan(intermediateValue)
|
||||
|
||||
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
|
||||
exact: true
|
||||
})
|
||||
await sampler.click()
|
||||
|
||||
await comfyPage.page.getByRole('searchbox').fill('uni')
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(sampler).toHaveText('uni_pc')
|
||||
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
|
||||
await expect(mobile.view).toBeVisible()
|
||||
await expect(mobile.navigation).toBeVisible()
|
||||
|
||||
await mobile.navigateTab('assets')
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
|
||||
|
||||
const buttons = await mobile.navigationTabs.all()
|
||||
await buttons[0].dragTo(buttons[2], { steps: 5 })
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
|
||||
|
||||
await mobile.navigateTab('run')
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
|
||||
|
||||
const steps = comfyPage.page.getByRole('spinbutton')
|
||||
const initialValue = Number(await steps.inputValue())
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'increment' }),
|
||||
{ count: 5 }
|
||||
)
|
||||
await expect(steps).toHaveValue(String(initialValue + 5))
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'decrement' }),
|
||||
{ count: 3 }
|
||||
)
|
||||
|
||||
await expect(steps).toHaveValue(String(initialValue + 2))
|
||||
})
|
||||
|
||||
test('workflow selection', async ({ comfyPage }) => {
|
||||
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
|
||||
for (const name of widgetNames)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
|
||||
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
|
||||
|
||||
const widgets = comfyPage.appMode.linearWidgets
|
||||
await comfyPage.appMode.mobile.navigateTab('run')
|
||||
for (let i = 0; i < widgetNames.length; i++) {
|
||||
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
|
||||
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
121
browser_tests/tests/appModeBuilder.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode builder selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Can independently select inputs of same name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
|
||||
await comfyPage.vueNodes.selectNodes(['6', '7'])
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
const prompts = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.locator('.lg-node-widget')
|
||||
const count = await prompts.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(prompts.nth(i)).toBeVisible()
|
||||
await prompts.nth(i).click()
|
||||
await expect(items).toHaveCount(i + 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('Can select outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToOutputs()
|
||||
|
||||
await comfyPage.nodeOps
|
||||
.getNodeRefById('9')
|
||||
.then((ref) => ref.centerOnNode())
|
||||
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
|
||||
await saveImage.click()
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await expect(items).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
|
||||
//Manually set error state on checkpoint loader
|
||||
//Shouldn't be needed on ci, but has spotty reliability
|
||||
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget(
|
||||
'Load Checkpoint',
|
||||
'ckpt_name'
|
||||
)
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
|
||||
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
|
||||
|
||||
await expect(items).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is initially editable'
|
||||
).toHaveCount(1)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Entering builder makes the canvas readonly'
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas remains readonly after pressing space'
|
||||
).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
|
||||
await ksampler.header.dblclick({ force: true })
|
||||
await expect(
|
||||
ksampler.titleEditor.input,
|
||||
'Double clicking node titles will not initiate a rename'
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is no longer readonly after exiting'
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
548
browser_tests/tests/keybindingPanel.spec.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
|
||||
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
|
||||
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
|
||||
|
||||
async function searchKeybindings(page: Page, query: string) {
|
||||
await getKeybindingSearchInput(page).fill(query)
|
||||
}
|
||||
|
||||
async function clearSearch(page: Page) {
|
||||
await getKeybindingSearchInput(page).clear()
|
||||
}
|
||||
|
||||
function getKeybindingSearchInput(page: Page): Locator {
|
||||
return page.getByPlaceholder('Search Keybindings...')
|
||||
}
|
||||
|
||||
function getCommandRow(page: Page, commandId: string): Locator {
|
||||
return page
|
||||
.locator('.keybinding-panel tr')
|
||||
.filter({ has: page.locator(`[title="${commandId}"]`) })
|
||||
}
|
||||
|
||||
function getExpansionContent(page: Page, commandId: string): Locator {
|
||||
// PrimeVue renders the expansion row as the next sibling <tr> of the
|
||||
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
|
||||
return getCommandRow(page, commandId)
|
||||
.locator('xpath=following-sibling::tr[1]')
|
||||
.getByTestId('keybinding-expansion-content')
|
||||
}
|
||||
|
||||
async function openContextMenu(page: Page, commandId: string) {
|
||||
const row = getCommandRow(page, commandId)
|
||||
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: /Change keybinding/i })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
function getKeybindingInput(page: Page): Locator {
|
||||
return getEditKeybindingDialog(page).locator('input[autofocus]')
|
||||
}
|
||||
|
||||
function getEditKeybindingDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Modify keybinding/i })
|
||||
}
|
||||
|
||||
function getRemoveAllKeybindingsDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Remove all keybindings/i })
|
||||
}
|
||||
|
||||
function getResetAllKeybindingsDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Reset all keybindings/i })
|
||||
}
|
||||
|
||||
async function pressComboOnInput(page: Page, combo: string) {
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeFocused()
|
||||
await input.press(combo)
|
||||
}
|
||||
|
||||
async function saveAndCloseKeybindingDialog(page: Page) {
|
||||
const dialog = getEditKeybindingDialog(page)
|
||||
await dialog.getByRole('button', { name: /Save/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
async function cancelAndCloseDialog(page: Page) {
|
||||
const dialog = getEditKeybindingDialog(page)
|
||||
await dialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
|
||||
await row.getByRole('button', { name: /Add new keybinding/i }).click()
|
||||
await pressComboOnInput(page, combo)
|
||||
await saveAndCloseKeybindingDialog(page)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await registerNoBindingCommand(comfyPage)
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
|
||||
})
|
||||
|
||||
async function registerNoBindingCommand(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate((commandId) => {
|
||||
const app = window.app!
|
||||
app.registerExtension({
|
||||
name: 'TestExtension.KeybindingPanelE2E',
|
||||
commands: [{ id: commandId, function: () => {} }]
|
||||
})
|
||||
}, NO_BINDING_COMMAND)
|
||||
}
|
||||
|
||||
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
test.describe('Row Expansion', () => {
|
||||
test('Click on row with 2+ keybindings toggles expansion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
|
||||
test('Click on row with 1 keybinding does not expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
|
||||
|
||||
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Double-Click', () => {
|
||||
test('Double-click row with 0 keybindings opens Add dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Double-click row with 1 keybinding opens Edit dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Context Menu', () => {
|
||||
test('Right-click row shows context menu with correct items', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const changeItem = page.getByRole('menuitem', {
|
||||
name: /Change keybinding/i
|
||||
})
|
||||
const addItem = page.getByRole('menuitem', {
|
||||
name: /Add new keybinding/i
|
||||
})
|
||||
const resetItem = page.getByRole('menuitem', {
|
||||
name: /Reset to default/i
|
||||
})
|
||||
const removeItem = page.getByRole('menuitem', {
|
||||
name: /Remove keybinding/i
|
||||
})
|
||||
|
||||
await expect(changeItem).toBeVisible()
|
||||
await expect(addItem).toBeVisible()
|
||||
await expect(resetItem).toBeVisible()
|
||||
await expect(removeItem).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test("Context menu 'Add new keybinding' opens add dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeHidden()
|
||||
|
||||
await openContextMenu(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
|
||||
|
||||
await expect(expansionContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F9')
|
||||
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
|
||||
|
||||
const confirmDialog = getRemoveAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
|
||||
|
||||
await expect(row.locator('td').nth(1)).toContainText('-')
|
||||
})
|
||||
|
||||
test("Context menu 'Reset to default' resets modified command", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F10')
|
||||
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
|
||||
|
||||
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Context menu items disabled when no keybindings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
await openContextMenu(page, NO_BINDING_COMMAND)
|
||||
|
||||
const changeItem = page.getByRole('menuitem', {
|
||||
name: /Change keybinding/i
|
||||
})
|
||||
const removeItem = page.getByRole('menuitem', {
|
||||
name: /Remove keybinding/i
|
||||
})
|
||||
|
||||
await expect(changeItem).toHaveAttribute('data-disabled', '')
|
||||
await expect(removeItem).toHaveAttribute('data-disabled', '')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Action Buttons', () => {
|
||||
test('Edit button opens edit dialog for single-binding command', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const editButton = row.getByRole('button', { name: /^Edit$/i })
|
||||
await expect(editButton).toBeVisible()
|
||||
await editButton.click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Add button opens add dialog', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await row.getByRole('button', { name: /Add new keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Reset button is disabled for unmodified commands', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const resetButton = row.getByRole('button', { name: /Reset/i })
|
||||
await expect(resetButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F11')
|
||||
|
||||
const resetButton = row.getByRole('button', { name: /Reset/i })
|
||||
await expect(resetButton).toBeEnabled()
|
||||
|
||||
await resetButton.click()
|
||||
|
||||
await expect(resetButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button is disabled for commands with 0 keybindings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await expect(deleteButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button removes single keybinding directly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F12')
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await expect(deleteButton).toBeEnabled()
|
||||
await deleteButton.click()
|
||||
|
||||
await expect(row.locator('td').nth(1)).toContainText('-')
|
||||
})
|
||||
|
||||
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await deleteButton.click()
|
||||
|
||||
const confirmDialog = getRemoveAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
await expect(row.locator('td').nth(1)).not.toContainText('-')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expanded Row Actions', () => {
|
||||
test('Edit button in expanded row opens edit dialog for that binding', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
const firstBindingRow = expansionContent
|
||||
.getByTestId('keybinding-expansion-binding')
|
||||
.first()
|
||||
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Delete button in expanded row removes that binding and collapses', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
const bindingRows = expansionContent.getByTestId(
|
||||
'keybinding-expansion-binding'
|
||||
)
|
||||
await expect
|
||||
.poll(() => bindingRows.count(), {
|
||||
message: 'Expected at least 2 bindings'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
const initialBindingCount = await bindingRows.count()
|
||||
|
||||
await bindingRows
|
||||
.first()
|
||||
.getByRole('button', { name: /Remove keybinding/i })
|
||||
.click()
|
||||
|
||||
if (initialBindingCount === 2) {
|
||||
// Expansion auto-collapses when bindings drop below 2
|
||||
await expect(expansionContent).toBeHidden()
|
||||
} else {
|
||||
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Reset All', () => {
|
||||
test('Reset All button shows confirmation and resets on confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F8')
|
||||
|
||||
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
|
||||
|
||||
await clearSearch(page)
|
||||
|
||||
const resetAllButton = page
|
||||
.locator('.keybinding-panel')
|
||||
.getByRole('button', { name: /Reset All/i })
|
||||
await resetAllButton.click()
|
||||
|
||||
const confirmDialog = getResetAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(
|
||||
rowAfterReset.getByRole('button', { name: /Reset/i })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const resetAllButton = page
|
||||
.locator('.keybinding-panel')
|
||||
.getByRole('button', { name: /Reset All/i })
|
||||
await resetAllButton.click()
|
||||
|
||||
const confirmDialog = getResetAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search Filter', () => {
|
||||
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
// Changing the filter triggers watch(filters, ...) which clears expansion
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
62
browser_tests/tests/metadataWorkflowImport.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type MetadataFixture = {
|
||||
fileName: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
// Each fixture embeds the same single-KSampler workflow (see
|
||||
// scripts/generate-embedded-metadata-test-files.py), exercising a different
|
||||
// parser in src/scripts/metadata/. Dropping the file should import that
|
||||
// workflow.
|
||||
const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
|
||||
{ fileName: 'with_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
for (const { fileName, parser } of FIXTURES) {
|
||||
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the fixture'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
test('Controls stack label above widget in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
'tool label should be visible in wide layout'
|
||||
).toBeVisible()
|
||||
|
||||
const wideLabelBox = await toolLabel.boundingBox()
|
||||
const wideBrushBox = await brushButton.boundingBox()
|
||||
expect(
|
||||
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
||||
'label should sit to the left of the brush button in wide layout'
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
'tool label should remain visible in compact layout'
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const labelBox = await toolLabel.boundingBox()
|
||||
const brushBox = await brushButton.boundingBox()
|
||||
if (!labelBox || !brushBox) return false
|
||||
return labelBox.y + labelBox.height <= brushBox.y
|
||||
},
|
||||
{
|
||||
message: 'label should stack above the brush button in compact layout'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
|
||||
@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph transition (enter and exit)',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }, testInfo) => {
|
||||
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
|
||||
// The signal is dominated by N=80 mount cost, so a single sample per
|
||||
// CI invocation is sufficient — early-return on subsequent repeats.
|
||||
if (testInfo.repeatEachIndex > 0) return
|
||||
|
||||
// Load workflow with a subgraph containing 80 interior nodes.
|
||||
// Entering the subgraph unmounts root nodes and mounts all 80 interior
|
||||
// nodes synchronously — this is the bottleneck we're measuring.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
|
||||
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
// Exit back to root graph before measuring a fresh enter/exit cycle
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.idleFrames(10)
|
||||
|
||||
// Start measuring the enter transition
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('workflow execution', async ({ comfyPage }) => {
|
||||
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
42
browser_tests/tests/previewAsText.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
// Simulate a previous execution: backend returned text and the frontend
|
||||
// populated the preview widget values. The next prompt submission must
|
||||
// NOT echo those values back as inputs (which would change the cache
|
||||
// signature and trigger a redundant re-execution).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.name?.startsWith('preview_')) {
|
||||
widget.value = 'rendered preview content from previous execution'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const previewEntry = Object.values(apiWorkflow).find(
|
||||
(n) => n.class_type === 'PreviewAny'
|
||||
)
|
||||
expect(previewEntry).toBeDefined()
|
||||
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
})
|
||||
63
browser_tests/tests/queueButtonModes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
|
||||
const runOnChangeLabel = queueModeLabels[1]
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await expect(options.modeItems).toHaveText(queueModeLabels)
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const queueButton = comfyPage.actionbar.queueButton
|
||||
const options = await queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await options.selectMode(runOnChangeLabel)
|
||||
|
||||
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
|
||||
})
|
||||
|
||||
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route('**/api/prompt', async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ test.describe(
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -1,40 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
test.describe(
|
||||
'Multi-instance subgraph promoted widget rendering in Vue mode',
|
||||
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectedByNodeId: Record<string, string> = {
|
||||
'11': 'Alpha\n',
|
||||
'12': 'Beta\n',
|
||||
'13': 'Gamma\n'
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes(3)
|
||||
|
||||
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const textarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(textarea).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -535,6 +535,75 @@ test.describe(
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
|
||||
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => getPromotedWidgetCount(comfyPage, '2'),
|
||||
'Primitive widget is restored on load'
|
||||
)
|
||||
.toBe(2)
|
||||
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
const promotedPrimitive = await subgraphNode!.getWidget(1)
|
||||
await expect
|
||||
.poll(
|
||||
() => promotedPrimitive.getValue(),
|
||||
'Primitive widget is not in a disconnected state'
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.fail(
|
||||
'Promoted text widget is removed when source node is deleted inside the subgraph',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.first()
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const subgraphNodeId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('text')
|
||||
await expect(
|
||||
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await interiorClip.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNodeAfter =
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
await expect(
|
||||
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,34 +14,6 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -526,63 +498,4 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(hostNode).toBeVisible()
|
||||
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(promotedTextbox).toHaveCount(1)
|
||||
await expect(promotedTextbox).toHaveValue('22222222222')
|
||||
|
||||
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
|
||||
|
||||
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const hostNode = serialized.nodes.find((node) => node.id === 4)
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return Array.isArray(proxyWidgets) ? proxyWidgets : []
|
||||
})
|
||||
|
||||
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
|
||||
expect(
|
||||
serializedProxyWidgets.every(
|
||||
(entry) => Array.isArray(entry) && entry.length === 2
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.describe('Closing a modified workflow tab (FE-419)', () => {
|
||||
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
|
||||
await page.evaluate(() => {
|
||||
const graph = window.app?.graph
|
||||
const node = window.LiteGraph?.createNode('Note')
|
||||
if (graph && node) graph.add(node)
|
||||
})
|
||||
await expect(
|
||||
activeTab.getByTestId('workflow-dirty-indicator')
|
||||
).toHaveCount(1)
|
||||
}
|
||||
|
||||
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Close anyway' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('clicking "Close anyway" closes the tab without saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Close anyway' })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('dismissing the dialog keeps the modified tab open', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -239,19 +239,18 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
|
||||
|
||||
Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, and Bevy — patterns adopted, departed, when to revisit |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# Appendix: ECS Pattern Survey
|
||||
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, and Bevy — captured during the world-consolidation analysis that
|
||||
shipped slice 1 of [ADR 0008](../adr/0008-entity-component-system.md). This
|
||||
appendix records which structural patterns our `src/world/` substrate adopts,
|
||||
which it deliberately departs from, and where the trade-offs are load-bearing
|
||||
rather than incidental._
|
||||
|
||||
The in-code anchors for the load-bearing constraints discussed below are the
|
||||
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
|
||||
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
|
||||
contract) — see §3 below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Survey Comparison
|
||||
|
||||
Five libraries were sampled for structural patterns: where component
|
||||
definitions live relative to the substrate, how components are declared,
|
||||
how entities are identified, and roughly how large the substrate's public
|
||||
surface is. Sources: the linked READMEs and docs.
|
||||
|
||||
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
|
||||
| ------------------------------------------------- | ------------------------------------ | ----------------------------- | -------------------- | ----------------------: |
|
||||
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
|
||||
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
|
||||
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
|
||||
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
|
||||
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
|
||||
|
||||
Two structural patterns are unanimous across the surveyed libraries:
|
||||
|
||||
1. **Component definitions live with the code that owns the data**, not
|
||||
inside the substrate package. Whether by explicit recommendation
|
||||
(Bevy plugins, koota's colocation guidance) or by default (bitECS,
|
||||
miniplex), no surveyed substrate ships pre-defined component types.
|
||||
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
|
||||
~15, miniplex at ~5. ECSY is the outlier with a wider class hierarchy.
|
||||
|
||||
Our slice-1 end state — five source files under
|
||||
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
|
||||
this band.
|
||||
|
||||
---
|
||||
|
||||
## 2. Patterns We Adopt
|
||||
|
||||
### 2.1 Substrate is deep; components live in domain code
|
||||
|
||||
The mainstream convention is that the ECS substrate exposes only the
|
||||
machinery — entities, component keys, a World — and component definitions
|
||||
live next to the system, store, or feature module that owns the data.
|
||||
This is the Bevy / miniplex / koota convention by design and the bitECS /
|
||||
ECSY convention by default.
|
||||
|
||||
Our substrate follows the same shape: `src/world/` contains entity-ID
|
||||
brands, the `ComponentKey` definition primitive, and the `World`
|
||||
interface, but no domain-specific component types. Slice 1 places
|
||||
`WidgetValueComponent` and `WidgetContainerComponent` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
|
||||
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
|
||||
module that already owns widget value state.
|
||||
|
||||
This keeps the substrate / domain seam crisp: the World knows how to store
|
||||
and look up arbitrary components keyed by entity ID; the domain layer
|
||||
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
|
||||
guidance to group code by bounded context. Future components follow the
|
||||
same rule — `PositionComponent`, when it lands, will live with the layout
|
||||
domain rather than inside the substrate.
|
||||
|
||||
### 2.2 Small public API
|
||||
|
||||
The substrate exports ~14 names — comparable to bitECS (~12) and koota
|
||||
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
|
||||
target: every exported name is a contract a contributor must understand
|
||||
before extending the World, and every export is a potential migration
|
||||
cost when the substrate evolves.
|
||||
|
||||
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
|
||||
split keeps each module single-purpose. `Brand<T,Tag>` is 5
|
||||
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
|
||||
carries a two-parameter phantom that enables cross-kind compile-time
|
||||
checking. `asGraphId` is a single named boundary cast. The two explicit
|
||||
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
|
||||
into a parameterized helper because slice 2/3/4 will add factories with
|
||||
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
|
||||
`slotEntityId`); the explicit-factory pattern scales linearly with new
|
||||
entity kinds without growing the helper's signature.
|
||||
|
||||
### 2.3 Reactive bridging via existing storage proxy
|
||||
|
||||
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
|
||||
a consumer wants reactive notifications. koota's React layer
|
||||
(`useTrait(entity, ComponentKey)`) is the closest analog to what
|
||||
`useUpstreamValue` and future composables want.
|
||||
|
||||
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
|
||||
a plain `computed(() => world.getComponent(id, key))` already provides
|
||||
fine-grained per-`(entity, component)` tracking — no separate event bus
|
||||
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
|
||||
the ECS storage are the same mechanism, so reactivity falls out of the
|
||||
storage choice rather than being layered on top.
|
||||
|
||||
### 2.4 Brand-typed entity IDs
|
||||
|
||||
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
|
||||
`number`, miniplex uses plain object references, koota uses a numeric
|
||||
`.id()`. Our `Brand<T, Tag>` over each entity kind enables the
|
||||
type-level cross-kind isolation assertion in
|
||||
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
|
||||
entity kinds at compile time.
|
||||
|
||||
This is a deliberate departure rather than an accident. It earns its keep
|
||||
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
|
||||
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
|
||||
component-key declarations would accept any numeric ID and silently allow
|
||||
cross-kind misuse.
|
||||
|
||||
---
|
||||
|
||||
## 3. Patterns We Explicitly Do NOT Adopt
|
||||
|
||||
Each of the following is a real industry idiom we considered and rejected
|
||||
on load-bearing grounds. None of these are pure performance trade-offs.
|
||||
|
||||
### 3.1 Replace-on-write usage idioms
|
||||
|
||||
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
|
||||
**replace** component values with new objects on each write. Adopting
|
||||
either would break
|
||||
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
|
||||
shared reactive identity — the contract that lets DOM widget overrides,
|
||||
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
|
||||
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
|
||||
and the inner `reactive(Map)` keeps a stable cached proxy per
|
||||
entity-component pair: every `getComponent` returns the same proxy,
|
||||
regardless of how many writes intervene. `widgetValueStore.registerWidget`
|
||||
returns that proxy (not the caller's input ref), so `BaseWidget._state`
|
||||
and every other reader observe the same object. Replace-on-write idioms
|
||||
would swap the cached proxy on each write and break that stability —
|
||||
the reactive-identity test in
|
||||
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
|
||||
locks in the contract.
|
||||
|
||||
### 3.2 SoA / archetype storage
|
||||
|
||||
bitECS, koota, and miniplex use sparse-set / archetype storage internally
|
||||
for cache locality. Our `reactive(Map<EntityId, unknown>)` is closer to
|
||||
ECSY's AoS — slower iteration but **integrates natively with Vue's
|
||||
tracking**.
|
||||
|
||||
The surface trade-off is performance; the deeper trade-off is identity.
|
||||
SoA storage spreads each component's fields across parallel typed arrays,
|
||||
so the per-entity "row object" is reconstructed on read. **A future
|
||||
migration to SoA would lose the proxy on the row object** — and with it
|
||||
the shared-reactive-identity contract that `BaseWidget._state` and the
|
||||
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
|
||||
just a perf optimization decision.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/world.ts](../../src/world/world.ts) — copied here for
|
||||
proximity:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* `setComponent` stores values by reference (no clone). The inner
|
||||
* `reactive(Map)` produces a single cached Vue proxy per entity-component
|
||||
* pair: every `getComponent` call returns the same proxy, and mutations
|
||||
* through it propagate to all readers. Note that the proxy is NOT `===`
|
||||
* to the raw object passed to `setComponent` — read through `getComponent`
|
||||
* (or a `registerWidget`-style helper that does so internally) and treat
|
||||
* that proxy as canonical.
|
||||
*
|
||||
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
|
||||
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
|
||||
* miniplex's `world.add(entity)`) would swap the cached proxy on each
|
||||
* write and break the contract; revisiting either consumer is required
|
||||
* before changing storage semantics.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.3 Auto-generated opaque entity IDs
|
||||
|
||||
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
|
||||
external structure. miniplex uses plain object references with the same
|
||||
property.
|
||||
|
||||
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
|
||||
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
|
||||
widget viewed at different subgraph depths shares identity with itself.
|
||||
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
|
||||
the same widget at depth 0 and depth 2 would receive different IDs and
|
||||
diverge.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Entity IDs are deterministic, content-addressed, and string-prefix
|
||||
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
|
||||
* consumers consistently pass `rootGraph.id` so widgets viewed at
|
||||
* different subgraph depths share identity. Migrating to numeric IDs
|
||||
* would break cross-subgraph value sharing. See ADR 0008 and
|
||||
* widgetValueStore for the canonical keying contract.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.4 Substrate-side parent/child relations
|
||||
|
||||
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
|
||||
ships first-class relations. These are useful when many subsystems need
|
||||
hierarchical traversal at storage-near speeds.
|
||||
|
||||
We treat hierarchical traversal as a domain-layer concern instead. The
|
||||
only structural relation slice 1 needs is `node → widgets` forward
|
||||
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
|
||||
and surfaced through `getNodeWidgets()` on the
|
||||
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
|
||||
`widget → node` lookup is not modeled in the World at all today —
|
||||
existing call sites already hold a widget object and read `widget.node`
|
||||
directly via the `BaseWidget` back-reference, so no substrate-side
|
||||
parent component earns its keep yet. We may revisit this if multiple
|
||||
slices need a shared traversal API; until then, keeping hierarchy
|
||||
domain-local preserves the substrate's "no domain knowledge" property.
|
||||
|
||||
---
|
||||
|
||||
## 4. When to Revisit
|
||||
|
||||
The choices in §3 are deliberate but not eternal. Each has a revisit
|
||||
threshold.
|
||||
|
||||
**SoA / archetype storage.** The break-even point against `reactive(Map)`
|
||||
iteration is roughly **>10k entities per component** in steady-state hot
|
||||
paths. ComfyUI's projected widget count through slice 4 stays well under
|
||||
that. The watch signal is whether a render-loop or solver-loop pass
|
||||
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
|
||||
or any successor query — not just micro-benchmarks of `Map.get`.
|
||||
|
||||
If we cross that threshold, the migration is non-trivial: SoA loses the
|
||||
proxy on the row object (see §3.2), so a SoA World must either
|
||||
reconstruct proxies on read (defeating the perf gain) or move
|
||||
shared-identity reads back to a domain-side cache. ADR 0008's
|
||||
"Render-Loop Performance Implications and Mitigations" section already
|
||||
enumerates the planned mitigations (frame-stable query caches, archetype
|
||||
buckets, profiling-gated storage upgrades behind the World API).
|
||||
|
||||
**Replace-on-write idioms.** Revisitable only if the 40+ extension
|
||||
ecosystem moves off `BaseWidget._state` shared identity entirely — a
|
||||
separate, larger slice with explicit cost analysis (re-entry, DOM widget
|
||||
options.getValue overrides, `linkedWidgets` fan-out,
|
||||
`useProcessedWidgets` memoization invalidation), out of scope for the
|
||||
current ADR 0008 implementation.
|
||||
|
||||
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
|
||||
contract is dropped. Today widget value sharing across subgraph depths
|
||||
depends on it; slice 2 may extend the same contract to `nodeEntityId`
|
||||
for spatial reads. Until the product requirement changes, opaque IDs
|
||||
would be a regression.
|
||||
|
||||
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
|
||||
need parent traversal. At one consumer it stays domain-local.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-References
|
||||
|
||||
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
|
||||
for the full target taxonomy and migration strategy.
|
||||
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
|
||||
end-state shape.
|
||||
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
|
||||
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
|
||||
independent verification of the architecture documents.
|
||||
@@ -1,637 +0,0 @@
|
||||
# Entity ID Strategy: Opaque IDs and Normalized Identity Components
|
||||
|
||||
_A design proposal for migrating all six entity kinds in the ECS taxonomy
|
||||
of [ADR 0008](../adr/0008-entity-component-system.md) — Node, Link, Widget,
|
||||
Slot, Reroute, Group — from primitive or content-addressed identifiers to
|
||||
**opaque UUIDs**, with identity data normalized into per-entity components
|
||||
and the necessary secondary indices owned by domain stores. This document
|
||||
is a follow-on to ADR 0008 and the [ECS Pattern Survey](appendix-ecs-pattern-survey.md).
|
||||
Status: proposed, pending sign-off before implementation._
|
||||
|
||||
The motivating defect is in
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts) (the slice-1
|
||||
identity contract for widgets and node containers), but the design
|
||||
question generalizes: **identity should be a component, not encoded into
|
||||
the entity-ID string, and entity IDs should be opaque across all kinds.**
|
||||
This document defines that strategy uniformly so subsequent ECS migration
|
||||
slices (per the [migration plan](ecs-migration-plan.md)) extend the same
|
||||
pattern instead of recreating the choice for each kind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 What ADR 0008 specifies
|
||||
|
||||
ADR 0008 §"Branded ID Design" specifies branded **numeric** IDs for all
|
||||
six entity kinds:
|
||||
|
||||
```ts
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' }
|
||||
```
|
||||
|
||||
The ADR notes that widgets and slots currently lack independent IDs and
|
||||
proposes to assign synthetic IDs at entity creation time via an
|
||||
auto-incrementing counter (matching the pattern used by `lastNodeId`,
|
||||
`lastLinkId` in `LGraphState`).
|
||||
|
||||
### 1.2 What the slice-1 implementation actually did
|
||||
|
||||
The first ECS slice ([src/world/](../../src/world/)) covers **widgets**
|
||||
and **node containers**, and it departed from ADR 0008's "numeric ID"
|
||||
contract in two ways:
|
||||
|
||||
1. IDs are **strings**, not numbers (`Brand<string, ...>`).
|
||||
2. IDs are **content-addressed** — `widgetEntityId(g, n, w)` is computed
|
||||
from `(graphId, nodeId, widgetName)`, not minted from a counter:
|
||||
|
||||
```
|
||||
node:${graphId}:${nodeId}
|
||||
widget:${graphId}:${nodeId}:${name}
|
||||
```
|
||||
|
||||
The departure was deliberate — content-addressing gives "an entity viewed
|
||||
at different subgraph depths shares state" for free — but the rationale
|
||||
was never recorded in an architecture doc, and it leaves the wire format
|
||||
unauthorized by ADR 0008.
|
||||
|
||||
### 1.3 Three problems with the slice-1 scheme
|
||||
|
||||
1. **Silent corruption when nodeId contains a colon.**
|
||||
`parseWidgetEntityId`'s regex `/^widget:([^:]+):([^:]+):(.*)$/` captures
|
||||
`nodeId` as `[^:]+`, so a string nodeId like `"sg:42"` produces
|
||||
`widget:g:sg:42:name`, which the regex mis-parses as `(g, sg, 42:name)`.
|
||||
`widgetValueStore.getNodeWidgetsByName` then keys the returned `Map` by
|
||||
the wrong name. The defect is latent today — verified: every production
|
||||
producer stringifies a bare local NodeId — but the type
|
||||
`NodeId = number | string` makes no runtime guarantee, and any future
|
||||
migration toward `NodeLocatorId` (e.g. `<subgraph-uuid>:<id>`) trips
|
||||
the bug.
|
||||
|
||||
2. **Identity is the address.** Renaming a widget mints a new entity
|
||||
(different name → different `widgetEntityId`); rewriting `nodeId`
|
||||
mints a new entity. There is no concept of "the widget formerly known
|
||||
as X" — per-aspect components attach to the address-derived ID and
|
||||
are abandoned at rename. For features that need stable identity
|
||||
across relabels (promoted widgets, subgraph reparenting, CRDT
|
||||
replication of label changes), this is a structural mismatch.
|
||||
|
||||
3. **Identity is recovered by parsing.** The regex is the only path by
|
||||
which `getNodeWidgetsByName` knows which widget has which name. The
|
||||
data has no representation in the World — it lives only in the string.
|
||||
Every consumer that needs the name must round-trip through the
|
||||
parser.
|
||||
|
||||
### 1.4 Why this generalizes
|
||||
|
||||
The same three problems apply, in principle, to every other entity kind
|
||||
that lacks an independent ID. **Slot** identity today is `(parent node,
|
||||
direction, index)`; if we encoded that as a colon-joined string, the same
|
||||
parser hazards reappear. **Link** identity is `LinkId = number` (already
|
||||
opaque), but its endpoints `(origin_id, origin_slot, target_id,
|
||||
target_slot)` are content-shaped — and any identity migration will face
|
||||
the same "do we encode this into the ID, or normalize it into a
|
||||
component?" choice. The design decision is the same across all six
|
||||
kinds; this document makes it once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Adopt the **opaque-entity-id + identity-as-component** pattern for all
|
||||
six entity kinds. The pattern matches what mainstream ECS libraries
|
||||
converge on (Flecs `(ChildOf, parent)` pairs, Bevy `Parent`+`Children`,
|
||||
koota `relation()`, EnTT `relationship.parent`, miniplex
|
||||
`entity.livesOn`); see §5 for the survey citations.
|
||||
|
||||
### 2.1 Entity IDs are opaque UUIDs across all kinds
|
||||
|
||||
```ts
|
||||
// src/world/entityIds.ts (proposed)
|
||||
export type NodeEntityId = Brand<string, 'NodeEntityId'>
|
||||
export type LinkEntityId = Brand<string, 'LinkEntityId'>
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
export type SlotEntityId = Brand<string, 'SlotEntityId'>
|
||||
export type RerouteEntityId = Brand<string, 'RerouteEntityId'>
|
||||
export type GroupEntityId = Brand<string, 'GroupEntityId'>
|
||||
export type EntityId =
|
||||
| NodeEntityId
|
||||
| LinkEntityId
|
||||
| WidgetEntityId
|
||||
| SlotEntityId
|
||||
| RerouteEntityId
|
||||
| GroupEntityId
|
||||
|
||||
export const mintNodeId = (): NodeEntityId =>
|
||||
crypto.randomUUID() as NodeEntityId
|
||||
export const mintLinkId = (): LinkEntityId =>
|
||||
crypto.randomUUID() as LinkEntityId
|
||||
export const mintWidgetId = (): WidgetEntityId =>
|
||||
crypto.randomUUID() as WidgetEntityId
|
||||
export const mintSlotId = (): SlotEntityId =>
|
||||
crypto.randomUUID() as SlotEntityId
|
||||
export const mintRerouteId = (): RerouteEntityId =>
|
||||
crypto.randomUUID() as RerouteEntityId
|
||||
export const mintGroupId = (): GroupEntityId =>
|
||||
crypto.randomUUID() as GroupEntityId
|
||||
```
|
||||
|
||||
UUIDs (rather than numeric counters) for three reasons. First, no
|
||||
coordination required — `crypto.randomUUID` is universally available and
|
||||
collision-resistant without `LGraphState.lastWidgetId++` bookkeeping.
|
||||
Second, they're stable across CRDT replication (per ADR 0003) without an
|
||||
ID-mapping layer. Third, the `Brand<string, ...>` type machinery in
|
||||
[src/world/brand.ts](../../src/world/brand.ts) already phantom-types over
|
||||
`string`, so retaining string concrete types keeps zero friction with
|
||||
existing component declarations.
|
||||
|
||||
This **amends ADR 0008 §"Branded ID Design"**: the `& { readonly __brand: ... }`
|
||||
discipline is preserved, but the underlying type is `string` (UUID) for
|
||||
all kinds, not `number`.
|
||||
|
||||
**Deletions from current code:** `nodeEntityId`, `widgetEntityId`,
|
||||
`parseWidgetEntityId`, `graphNodePrefix`, `graphWidgetPrefix`,
|
||||
`isNodeIdForGraph`, `isWidgetIdForGraph`, the regex. The wire-format
|
||||
concept goes away.
|
||||
|
||||
**Litegraph-numeric IDs are preserved as data** — `LGraphState.lastNodeId`
|
||||
and friends continue to assign legacy numeric IDs that workflow JSON
|
||||
serialization depends on (per ADR 0008 §"The internal ECS model and the
|
||||
serialization format are deliberately separate concerns"). The legacy
|
||||
numeric ID becomes a field on the entity's identity component (§2.2),
|
||||
not the entity's identity itself.
|
||||
|
||||
### 2.2 Identity components per entity kind
|
||||
|
||||
Each entity kind gets a "BelongsTo"-style component carrying its
|
||||
structural relationships (parent pointers) and any legacy identifiers
|
||||
needed for serialization parity. This is the canonical "parent pointer
|
||||
on child" pattern; see §3.1 and §5.
|
||||
|
||||
#### Node
|
||||
|
||||
```ts
|
||||
export const NodeBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphNodeId: NodeId // legacy `LGraphNode.id`, kept for serialization
|
||||
},
|
||||
NodeEntityId
|
||||
>('NodeBelongsTo')
|
||||
```
|
||||
|
||||
Nodes belong to a graph (their containing `LGraph` or `Subgraph`).
|
||||
`litegraphNodeId` preserves the local-to-graph numeric ID that
|
||||
`workflowSchema.json` round-tripping requires.
|
||||
|
||||
#### Link
|
||||
|
||||
```ts
|
||||
export const LinkBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphLinkId: LinkId // legacy `LLink.id`
|
||||
},
|
||||
LinkEntityId
|
||||
>('LinkBelongsTo')
|
||||
|
||||
export const LinkEndpoints = defineComponentKey<
|
||||
{
|
||||
origin: SlotEntityId
|
||||
target: SlotEntityId
|
||||
type: ISlotType
|
||||
},
|
||||
LinkEntityId
|
||||
>('LinkEndpoints')
|
||||
```
|
||||
|
||||
`LinkEndpoints` (already proposed in ADR 0008 §"Component Decomposition")
|
||||
becomes the structural relationship; under the new scheme its fields are
|
||||
opaque `SlotEntityId`s, not `(node_id, slot_index)` tuples.
|
||||
|
||||
#### Widget
|
||||
|
||||
```ts
|
||||
export const WidgetBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
nodeId: NodeEntityId // parent pointer
|
||||
name: string // identity within the parent
|
||||
},
|
||||
WidgetEntityId
|
||||
>('WidgetBelongsTo')
|
||||
```
|
||||
|
||||
The widget-on-node parent pointer is the bug-relevant case. `name`
|
||||
identifies the widget within its parent node and replaces the
|
||||
parser-recovered name in `widgetEntityId`'s wire format.
|
||||
|
||||
#### Slot
|
||||
|
||||
```ts
|
||||
export const SlotBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
nodeId: NodeEntityId // parent pointer
|
||||
direction: 'input' | 'output'
|
||||
index: number // ordinal within the parent's input/output array
|
||||
name: string
|
||||
},
|
||||
SlotEntityId
|
||||
>('SlotBelongsTo')
|
||||
```
|
||||
|
||||
Slots are an extreme case of "identity is currently the address" —
|
||||
today's identity is literally `(node, direction, index)`, with the index
|
||||
shifting whenever a slot is inserted or removed. Under opaque UUIDs, slot
|
||||
identity persists across reorderings; the `index` field becomes a
|
||||
position in an ordered children list, not a name.
|
||||
|
||||
#### Reroute
|
||||
|
||||
```ts
|
||||
export const RerouteBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
parentRerouteId: RerouteEntityId | null // optional reroute parent
|
||||
litegraphRerouteId: RerouteId // legacy `Reroute.id`
|
||||
},
|
||||
RerouteEntityId
|
||||
>('RerouteBelongsTo')
|
||||
```
|
||||
|
||||
Reroutes can chain (`Reroute.parentId`); the parent-pointer pattern
|
||||
applies recursively. Reroutes also reference the link they belong to via
|
||||
`RerouteLinks` (ADR 0008 §"Reroute"); under opaque IDs that becomes a
|
||||
list of `LinkEntityId`s.
|
||||
|
||||
#### Group
|
||||
|
||||
```ts
|
||||
export const GroupBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphGroupId: number // legacy `LGraphGroup` numeric id
|
||||
},
|
||||
GroupEntityId
|
||||
>('GroupBelongsTo')
|
||||
|
||||
export const GroupChildren = defineComponentKey<
|
||||
{ entityIds: (NodeEntityId | RerouteEntityId)[] },
|
||||
GroupEntityId
|
||||
>('GroupChildren')
|
||||
```
|
||||
|
||||
Groups own a heterogeneous children list (nodes and reroutes within
|
||||
their bounds). `GroupChildren` is the denormalized cache (§2.3); each
|
||||
member also carries an optional `MemberOf` back-pointer if downward
|
||||
iteration is hot (it isn't currently).
|
||||
|
||||
### 2.3 Children lists on parents — denormalized caches
|
||||
|
||||
Where downward iteration is hot, the parent gets a denormalized
|
||||
children-list component. The existing
|
||||
[`WidgetComponentContainer`](../../src/world/widgets/widgetComponents.ts)
|
||||
on the node side is the canonical example; this generalizes to:
|
||||
|
||||
| Parent | Children component | Hot path justifying it |
|
||||
| ------ | ------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| Node | `WidgetComponentContainer { widgetIds: [...] }` | Per-frame Vue-node rendering |
|
||||
| Node | `SlotChildren { inputs: [...], outputs: [...] }` | Connection drawing, hit-testing |
|
||||
| Graph | `GraphMembers { nodeIds: [...], linkIds: [...], rerouteIds: [...], groupIds: [...] }` | Top-level rendering, `clearGraph` |
|
||||
| Group | `GroupChildren { entityIds: [...] }` | Group-bounding-box render |
|
||||
| Link | `RerouteLinks { rerouteIds: [...] }` | Path rendering along reroute chain |
|
||||
|
||||
These are caches **derived from** the per-child `*BelongsTo` parent
|
||||
pointer. They exist for performance and are maintained by the same
|
||||
mutation API that owns the parent pointer (§2.4); callers outside that
|
||||
API never write to them directly. This is the Bevy
|
||||
`Parent`+`Children`-symmetric-management discipline.
|
||||
|
||||
### 2.4 Secondary indices live in domain stores
|
||||
|
||||
Under opaque UUIDs, the question "given some content-shaped key, find
|
||||
the entity" is no longer answered by computing a string. Each domain
|
||||
store keeps a private forward index whose key is built with
|
||||
`makeCompositeKey` (already in
|
||||
[src/utils/compositeKey.ts](../../src/utils/compositeKey.ts)) so the
|
||||
encoding is injective by construction:
|
||||
|
||||
| Store | Forward index | Key shape |
|
||||
| --------------------- | -------------------- | ------------------------------------------- |
|
||||
| `widgetValueStore` | `widgetByAddress` | `ckey([graphId, nodeId, widgetName])` |
|
||||
| `widgetValueStore` | `nodeByAddress` | `ckey([graphId, litegraphNodeId])` |
|
||||
| (slot store, future) | `slotByAddress` | `ckey([graphId, nodeId, direction, index])` |
|
||||
| (link store, future) | `linkByLitegraphId` | `ckey([graphId, litegraphLinkId])` |
|
||||
| (group store, future) | `groupByLitegraphId` | `ckey([graphId, litegraphGroupId])` |
|
||||
|
||||
Plus per-graph entity sets for O(set-size) bulk clear, replacing the
|
||||
slice-1 `isWidgetIdForGraph` startsWith-scan:
|
||||
|
||||
```ts
|
||||
const widgetsByGraph = new Map<GraphId, Set<WidgetEntityId>>()
|
||||
const nodesByGraph = new Map<GraphId, Set<NodeEntityId>>()
|
||||
// (and analogous per-kind sets in each store as kinds migrate)
|
||||
```
|
||||
|
||||
All indices are private and mutated only by the owning store's public
|
||||
methods (`registerWidget` / `clearGraph` / a future `unregisterWidget`).
|
||||
This is the centralized-mutation discipline that Bevy's hierarchy
|
||||
plugin enforces through `Commands` extension methods — and that the ECS
|
||||
literature unanimously identifies as the precondition for safely
|
||||
denormalizing hierarchy state.
|
||||
|
||||
### 2.5 Surface area summary
|
||||
|
||||
| Layer | Primitive | Role |
|
||||
| ----------------------------- | --------------------------------------------- | -------------------------------------- |
|
||||
| `entityIds.ts` | `mint{Node,Link,Widget,Slot,Reroute,Group}Id` | Opaque ID generation |
|
||||
| domain `*Components.ts` files | `*BelongsTo` components | Parent pointers (identity) |
|
||||
| domain `*Components.ts` files | `*Children` / `*Container` components | Denormalized caches |
|
||||
| domain stores | `*ByAddress` indices | Forward content-address lookup |
|
||||
| domain stores | `*ByGraph` indices | Per-graph bulk clear |
|
||||
| domain store public methods | `register*` / `clearGraph` / `unregister*` | Sole writers to indices and components |
|
||||
|
||||
---
|
||||
|
||||
## 3. Why this is the right shape
|
||||
|
||||
### 3.1 It's what the ECS literature converges on
|
||||
|
||||
Three independent surveys (Flecs/Bevy/EnTT, koota/miniplex, and
|
||||
normalization-tradeoff literature) agree on the same finding:
|
||||
**store the parent pointer on the child, add a denormalized children
|
||||
list on the parent only when downward iteration is hot, and centralize
|
||||
all mutations behind a small API**. This document proposes exactly that
|
||||
pattern — uniformly across all six entity kinds.
|
||||
|
||||
### 3.2 It removes parsers as an attack surface — for every kind
|
||||
|
||||
`parseWidgetEntityId` does not exist in the new scheme. The temptation
|
||||
to write a `parseSlotEntityId` (which would face the same hazards on
|
||||
slot names) is removed before it appears. Identity is read structurally
|
||||
from components.
|
||||
|
||||
### 3.3 It decouples address from identity
|
||||
|
||||
Today, `widget(g, n, "old-name")` and `widget(g, n, "new-name")` are
|
||||
different entities. Slot reordering today changes slot identity
|
||||
(because identity = ordinal index). Reparenting today rotates entity IDs
|
||||
across graphs. Under opaque UUIDs none of these mutations rotates
|
||||
identity — they're component edits. This unlocks features that need
|
||||
identity continuity (promoted-widget identity preservation,
|
||||
slot-reorder-without-reconnection, label-swapping in CRDT replication)
|
||||
without touching the substrate.
|
||||
|
||||
### 3.4 It aligns with ADRs 0003 and 0008
|
||||
|
||||
ADR 0003's command pattern requires that all mutations be serializable
|
||||
and replayable. Opaque UUIDs are easier to serialize coherently — they
|
||||
don't carry a graphId-prefix that needs rewriting on subgraph remount,
|
||||
and they don't depend on the serialization format encoding identity
|
||||
tuples identically across versions. Component-resident identity makes
|
||||
command shapes ("set `WidgetBelongsTo.name` on `<uuid>`") trivially
|
||||
expressible.
|
||||
|
||||
ADR 0008's "world is the source of truth, serialization is a
|
||||
translation" principle is reinforced: the World holds opaque UUIDs and
|
||||
identity components; the SerializationSystem maps them to/from the
|
||||
`workflowSchema.json` format that uses litegraph-numeric IDs.
|
||||
|
||||
### 3.5 It absorbs the colon-collision fix as a side effect
|
||||
|
||||
The bug that motivates this document is solved twice over by the design:
|
||||
the parser is gone, AND the address index uses `makeCompositeKey`
|
||||
(already injective). No assert, no regex, no caveat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Costs and risks
|
||||
|
||||
### 4.1 Index hygiene — across more stores
|
||||
|
||||
For each migrated kind, the per-store invariant is the same: indices
|
||||
must stay in lockstep with the World. The discipline that makes this
|
||||
safe is API-confinement: **all mutation goes through the owning store's
|
||||
public methods**. Direct `world.setComponent` calls bypass the indices
|
||||
and corrupt them.
|
||||
|
||||
Mitigations (apply per-store):
|
||||
|
||||
- Document the contract in the file's top doc-comment.
|
||||
- Add a unit test that asserts the indices match the World contents
|
||||
after a randomized sequence of register/clear operations.
|
||||
- Optionally, add a debug-build-only `world.assertConsistentIndices()`
|
||||
check.
|
||||
|
||||
### 4.2 Debuggability of opaque IDs
|
||||
|
||||
UUIDs in stack traces require reading the corresponding `*BelongsTo`
|
||||
component to recover the human-meaningful identity. This applies to
|
||||
every kind, not just widgets.
|
||||
|
||||
Mitigations:
|
||||
|
||||
- Provide a `describeEntity(id)` helper per store that returns the
|
||||
identity tuple for logging.
|
||||
- Vue DevTools / Pinia inspector should surface the `*BelongsTo`
|
||||
components for registered entities.
|
||||
|
||||
### 4.3 Migration effort, by kind
|
||||
|
||||
The slices below mirror the [migration plan](ecs-migration-plan.md)'s
|
||||
incremental approach. Each kind ships independently:
|
||||
|
||||
| Kind | Status today | Migration cost |
|
||||
| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Widget | In World (slice 1, content-addressed strings) | High: rewrite `entityIds.ts`, `widgetValueStore.ts`, ~6 test files. ~+250/-80 prod, ~+100 test. |
|
||||
| Node container | In World (slice 1, content-addressed strings) | Bundled with widget migration; same surface area. |
|
||||
| Slot | Not in World; lives on `LGraphNode.{inputs,outputs}` | Per ADR 0008 migration plan. New: `slotEntityId`, `SlotBelongsTo`, slot store. |
|
||||
| Link | Not in World; lives on `LGraph.links` | New: `linkEntityId`, `LinkBelongsTo`, `LinkEndpoints` (slot-keyed). |
|
||||
| Reroute | Not in World; lives on `LGraph.reroutes` | New: `rerouteEntityId`, `RerouteBelongsTo`, `RerouteLinks`. |
|
||||
| Group | Not in World; lives on `LGraph._groups` | New: `groupEntityId`, `GroupBelongsTo`, `GroupChildren`. |
|
||||
|
||||
The widget+node migration is the only slice that touches existing World
|
||||
code and existing tests. All other kinds are greenfield extensions — the
|
||||
strategy is fixed by this document so each slice executes mechanically
|
||||
without re-litigating identity-format choices.
|
||||
|
||||
### 4.4 Re-registration after `clearGraph`
|
||||
|
||||
If `clearGraph(g)` cleans World components but leaves stale entries in
|
||||
the per-store address indices, a subsequent `register*(g, ...)` returns
|
||||
an old UUID and re-binds components on a "ghost" entity. This is
|
||||
functionally equivalent to today's content-addressed re-use, but the
|
||||
discipline must be enforced: **`clearGraph(g)` MUST clear all index
|
||||
entries for graph `g` before returning.** Per-store unit tests should
|
||||
include a `clear → re-register` round-trip case.
|
||||
|
||||
### 4.5 Tests that hand-construct entity IDs
|
||||
|
||||
Slice-1 tests synthesize `WidgetEntityId`/`NodeEntityId` strings directly
|
||||
(e.g. `defineComponentKey<...>` typing assertions in
|
||||
[world.test.ts](../../src/world/world.test.ts)). These will need to mint
|
||||
UUIDs instead. The change is mechanical but touches several test files
|
||||
and will recur for each subsequent slice.
|
||||
|
||||
### 4.6 Litegraph-numeric ID coexistence
|
||||
|
||||
`workflowSchema.json` round-tripping requires preserving the numeric IDs
|
||||
emitted by `LGraphState.lastNodeId++` etc. Under opaque-UUID identity
|
||||
those numbers become **data** on the `*BelongsTo` component
|
||||
(`litegraphNodeId`, `litegraphLinkId`, `litegraphGroupId`,
|
||||
`litegraphRerouteId`). The SerializationSystem reads them when emitting
|
||||
JSON and assigns matching values when re-hydrating. This is the same
|
||||
"World ID ≠ wire ID" decoupling that ADR 0008 requires.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-references
|
||||
|
||||
### 5.1 ECS library survey
|
||||
|
||||
Detailed comparison in [appendix-ecs-pattern-survey.md](appendix-ecs-pattern-survey.md).
|
||||
The pattern this document proposes — opaque IDs + parent-pointer-on-child
|
||||
|
||||
- denormalized children + centralized mutation — is what koota,
|
||||
miniplex, EnTT, and Bevy all converge on for hierarchy modeling. Flecs
|
||||
goes further by encoding the parent into the archetype, which JS ECSes
|
||||
don't have access to without significant substrate work.
|
||||
|
||||
### 5.2 Source citations for §2 and §3.1
|
||||
|
||||
- Flecs hierarchies and pairs:
|
||||
[Hierarchies Manual](https://www.flecs.dev/flecs/md_docs_2HierarchiesManual.html),
|
||||
[Relationships Manual](https://www.flecs.dev/flecs/md_docs_2Relationships.html)
|
||||
- Bevy parent/children components:
|
||||
[bevy_hierarchy docs](https://docs.rs/bevy_hierarchy),
|
||||
[Bevy Cheat Book hierarchy](https://bevy-cheatbook.github.io/fundamentals/hierarchy.html)
|
||||
- EnTT relationship guidance:
|
||||
skypjack, [ECS back and forth, part 4](https://skypjack.github.io/2019-09-25-ecs_baf_part_4/)
|
||||
- koota relation primitive:
|
||||
[koota/relation source](https://github.com/pmndrs/koota/blob/main/packages/core/src/relation/relation.ts)
|
||||
- miniplex entity-reference pattern:
|
||||
[miniplex README](https://github.com/hmans/miniplex)
|
||||
- Normalization tradeoffs:
|
||||
Mertens, [Building Games in ECS with Entity Relationships](https://ajmmertens.medium.com/building-games-in-ecs-with-entity-relationships-657275ba2c6c);
|
||||
IceFall Games,
|
||||
[Managing game object hierarchy in an ECS](https://mtnphil.wordpress.com/2014/06/09/managing-game-object-hierarchy-in-an-entity-component-system/)
|
||||
|
||||
### 5.3 Related ADRs and architecture docs
|
||||
|
||||
- [ADR 0003: Centralized Layout Management with CRDT](../adr/0003-crdt-based-layout-system.md) —
|
||||
command-pattern requirement that opaque IDs simplify
|
||||
- [ADR 0008: Entity Component System](../adr/0008-entity-component-system.md) —
|
||||
this document amends §"Branded ID Design" by reinterpreting "numeric"
|
||||
as "opaque UUID string" across all six entity kinds, and adds
|
||||
`*BelongsTo` identity components to §"Component Decomposition"
|
||||
- [ECS Pattern Survey](appendix-ecs-pattern-survey.md) — substrate-level
|
||||
comparison
|
||||
- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — concrete
|
||||
before/after walkthroughs that this document's identity model affects
|
||||
- [ECS Migration Plan](ecs-migration-plan.md) — the slice progression
|
||||
this document's per-kind sections track against
|
||||
- [ECS Target Architecture](ecs-target-architecture.md) — the world-shape
|
||||
this document refines for identity
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration plan
|
||||
|
||||
Two horizons: a near-term implementation slice that ships now, and a
|
||||
strategy locked in for subsequent ADR 0008 slices.
|
||||
|
||||
### 6.1 Near-term: widgets and node containers (slice 1.5)
|
||||
|
||||
Ship as a single PR after the colon-collision consolidation
|
||||
([entityIds-consolidation.md](../../temp/plans/entityIds-consolidation.md))
|
||||
lands and is verified. Sequencing rationale: the consolidation is a
|
||||
defensive 1-file fix that does not commit to this larger architectural
|
||||
direction. If this proposal is rejected or amended, the consolidation
|
||||
still stands as a strict improvement over the current state.
|
||||
|
||||
Phases inside the PR (single commit boundary, but reviewable as separate
|
||||
hunks):
|
||||
|
||||
1. Add `WidgetBelongsTo`, `NodeBelongsTo` components and the four
|
||||
indices in `widgetValueStore`. Populate them in `registerWidget`
|
||||
alongside the existing entity-ID derivation. The string-format
|
||||
`widgetEntityId` continues to exist; the new components are written
|
||||
redundantly. All tests still pass.
|
||||
2. Switch `getWidget` / `getNodeWidgets` / `getNodeWidgetsByName` to
|
||||
read identity from `WidgetBelongsTo` instead of parsing the entity
|
||||
ID. Delete `parseWidgetEntityId`. Delete the regex.
|
||||
3. Switch `clearGraph` to consume `widgetsByGraph` / `nodesByGraph`
|
||||
instead of `entitiesWith` + `isWidgetIdForGraph`. Delete the
|
||||
`isXForGraph` helpers.
|
||||
4. Replace `widgetEntityId` / `nodeEntityId` constructors with
|
||||
`mintWidgetId` / `mintNodeId`. Switch the entity-ID format from
|
||||
content-addressed strings to UUIDs.
|
||||
5. Update test fixtures that hand-construct entity-ID strings.
|
||||
|
||||
Each phase ends in a green test suite. Quality gates:
|
||||
`pnpm test:unit && pnpm typecheck && pnpm lint && pnpm knip`.
|
||||
|
||||
### 6.2 Subsequent slices: slots, links, reroutes, groups
|
||||
|
||||
Per the [migration plan](ecs-migration-plan.md), each remaining kind
|
||||
moves into the World in its own slice. For each, the strategy is fixed
|
||||
by this document:
|
||||
|
||||
1. Add the kind's `mint*Id` to `entityIds.ts` and the `*EntityId` brand.
|
||||
2. Define the `*BelongsTo` identity component plus any `*Children` /
|
||||
`*Endpoints` structural components per §2.2.
|
||||
3. Create (or extend) the domain store that owns the per-graph and
|
||||
forward-address indices, with the centralized-mutation API discipline.
|
||||
4. Add the bridge layer (per ADR 0008 §"Migration Strategy" step 2) so
|
||||
existing OOP consumers continue to read from the litegraph class
|
||||
while ECS readers read from the World.
|
||||
5. Migrate consumers piecewise per ADR 0008 §"Migration Strategy"
|
||||
steps 3–5.
|
||||
|
||||
No new identity-format decisions are made per slice. The colon-collision
|
||||
risk class is closed for the entire ECS, not just for widgets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. **Should widgets and node containers ship in one PR or two?** Widget
|
||||
identity is the bug-relevant case; node identity is symmetric but not
|
||||
load-bearing. Splitting reduces review surface but leaves the
|
||||
substrate temporarily inconsistent (widgets opaque, node containers
|
||||
content-addressed). Recommendation: one PR, since both live in
|
||||
`widgetValueStore` and the indices interlock.
|
||||
|
||||
2. **Should `*BelongsTo.name` / `*BelongsTo.index` be mutable post-creation?**
|
||||
Today, rename mints a new entity; reorder rotates slot identity.
|
||||
Under opaque IDs these mutations could either (a) preserve identity
|
||||
(mutate component, rewrite address index) or (b) preserve current
|
||||
semantics (delete + remint). (a) is the new capability this refactor
|
||||
unlocks, but adopting it changes downstream behavior; should be a
|
||||
deliberate follow-up per kind.
|
||||
|
||||
3. **Do we want explicit `Is{Widget,Node,Slot,…}` marker components?**
|
||||
koota uses tag traits this way so `world.entitiesWith(IsWidget)` is
|
||||
the canonical "iterate all widgets" query, replacing the implicit
|
||||
`entitiesWith(WidgetComponentValue)` pattern. Optional ergonomic
|
||||
improvement, not required by the bug fix; decide per slice.
|
||||
|
||||
4. **Where does name/ordinal uniqueness within a parent belong?**
|
||||
For widgets: `(graphId, nodeId, name)` uniqueness is enforced by
|
||||
`widgetByAddress`. For slots: `(graphId, nodeId, direction, index)`
|
||||
uniqueness is enforced by `slotByAddress`. We should decide per kind
|
||||
whether second-registration is a no-op (`getOrRegister` semantics),
|
||||
an overwrite, or an error.
|
||||
|
||||
5. **Counter-based vs UUID-based minting.** ADR 0008 originally proposed
|
||||
counter-based (`lastWidgetId++`). UUIDs are simpler (no shared state,
|
||||
no CRDT mapping) but slightly larger and unpleasant in stack traces.
|
||||
Recommendation: UUIDs for now (matching this document's §2.1); revisit
|
||||
if profiling or debuggability demands otherwise.
|
||||
128
docs/architecture/extension-api-v2/README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# v2 Extension API — Touch-Point Database
|
||||
|
||||
This directory is the **canonical compatibility-surface map** for the upcoming
|
||||
v2 extension API redesign. Every API surface that real-world ComfyUI
|
||||
extensions touch is enumerated here, weighted by usage frequency and ecosystem
|
||||
star count, with citations to verifiable evidence (file paths and line
|
||||
numbers in real custom-node repos).
|
||||
|
||||
It exists so the v2 redesign can answer two questions deterministically:
|
||||
|
||||
1. **What will silently break?** — every entry maps to a v2 replacement (or to
|
||||
an explicit "deprecated, no replacement" decision).
|
||||
2. **What does the v2 test framework need to cover?** — every entry maps to
|
||||
≥1 test target so the test floor reflects real extension shapes.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`touch-points-plan.md`](./touch-points-plan.md) | Methodology, schema, surface-family enumeration, severity rubric |
|
||||
| [`touch-points-database.yaml`](./touch-points-database.yaml) | Source of truth — 52 patterns × 15 surface families with evidence rows |
|
||||
| [`touch-points-star-cache.yaml`](./touch-points-star-cache.yaml) | GitHub star/fork/last-commit snapshot for every cited repo (drift detection) |
|
||||
| [`touch-points-rollup.yaml`](./touch-points-rollup.yaml) | Computed blast-radius scores per pattern (sorted) — the prioritization output |
|
||||
| [`scripts/fetch-stars.sh`](./scripts/fetch-stars.sh) | Refresh the star cache via `gh api` |
|
||||
| [`scripts/rollup-blast-radius.py`](./scripts/rollup-blast-radius.py) | Recompute blast radius from database + star cache |
|
||||
| [`scripts/add-evidence.py`](./scripts/add-evidence.py) | Idempotently merge new evidence rows / new patterns into the database |
|
||||
|
||||
## The 15 surface families
|
||||
|
||||
| Family | One-liner |
|
||||
|---|---|
|
||||
| **S1** | `ComfyExtension` lifecycle hooks (`init`, `setup`, `nodeCreated`, `beforeRegisterNodeDef`, …) |
|
||||
| **S2** | `LGraphNode.prototype` methods extensions monkey-patch (`onConnectionsChange`, `onSerialize`, `onDrawForeground`, …) |
|
||||
| **S3** | `LGraphCanvas.prototype` methods extensions monkey-patch (`processKey`, `processContextMenu`, `drawNode`, …) |
|
||||
| **S4** | Widget-level patterns — `.callback` chaining, `.value` r/w, `.serializeValue`, `.options.*`, DOM widgets |
|
||||
| **S5** | `ComfyApi` / `app.api` event surfaces — execution lifecycle WebSocket events |
|
||||
| **S6** | `ComfyApp` god-object touch points — `app.graphToPrompt`, `app.queuePrompt`, `app.api.fetchApi`, … |
|
||||
| **S7** | Window / global escape hatches — `window.app`, `window.LiteGraph`, `globalThis.LGraphCanvas` |
|
||||
| **S8** | Special node properties (magic flags) — `isVirtualNode`, `serialize_widgets`, `category`, `color_on` |
|
||||
| **S9** | Non-Node entity kinds (per [ADR 0008](../decisions/0008-entity-taxonomy.md)) — subgraphs, groups, reroutes, links |
|
||||
| **S10** | Dynamic node API — `addInput` / `removeInput` / `addOutput` / `removeOutput` slot mutation at runtime |
|
||||
| **S11** | Graph-level state and change-tracking — `graph.add`, `graph.remove`, `graph.serialize`, version bumps |
|
||||
| **S12** | Shell UI registries — `extensionManager.registerSidebarTab`, bottom panel, commands, toasts |
|
||||
| **S13** | Schema interpretation — `ComfyNodeDef` / `InputSpec` consumers (validation, default values, type coercion) |
|
||||
| **S14** | Identity / Locator scheme — node IDs, slot keys, widget identity across reload |
|
||||
| **S15** | Output system — preview-image / preview-any / display-text axis (per `widget-api-thoughts.md`) |
|
||||
|
||||
Full details, schema, and severity rubric are in [`touch-points-plan.md`](./touch-points-plan.md).
|
||||
|
||||
## Top 12 patterns by blast radius
|
||||
|
||||
Computed from [`touch-points-rollup.yaml`](./touch-points-rollup.yaml). Blast
|
||||
radius is `log10(1+stars)·1.0 + log10(1+occurrences)·0.7 +
|
||||
(signature_count-1)·0.5 + silent_breakage·0.5 + lifecycle_coupling·0.4`.
|
||||
|
||||
| Rank | BR | ★ sum | occ | sig | Pattern | Surface |
|
||||
|---:|---:|---:|---:|---:|---|---|
|
||||
| 1 | 6.67 | 17 101 | 7 | 1 | `S6.A1` | `app.graphToPrompt` monkey-patching ⚠️ CRITICAL |
|
||||
| 2 | 5.42 | 2 567 | 1 | 1 | `S9.SG1` | Subgraph "set/get virtual node" pattern (KJNodes-style) |
|
||||
| 3 | 5.27 | 4 314 | 4 | 1 | `S11.G2` | `graph.add` / `graph.remove` / `graph.findNodesByType` / `graph.findNodeById` / `graph.serialize` / `graph.configure` |
|
||||
| 4 | 5.23 | 1 808 | 3 | 1 | `S10.D1` | `node.addInput` / `node.removeInput` / `node.addOutput` / `node.removeOutput` dynamic slot mutation |
|
||||
| 5 | 5.18 | 3 049 | 5 | 1 | `S2.N13` | `nodeType.prototype.onConnectOutput` patching |
|
||||
| 6 | 5.08 | 6 147 | 4 | 1 | `S4.W2` | `node.addDOMWidget(name, type, element, options)` |
|
||||
| 7 | 5.01 | 412 | 6 | 1 | `S2.N15` | `nodeType.prototype.serialize` / `node.serialize` direct method patching |
|
||||
| 8 | 4.89 | 1 789 | 4 | 1 | `S2.N14` | `nodeType.prototype.onWidgetChanged` patching |
|
||||
| 9 | 4.89 | 7 932 | 6 | 1 | `S2.N4` | `nodeType.prototype.onRemoved` patching (de-facto teardown) |
|
||||
| 10 | 4.66 | 1 837 | 6 | 1 | `S4.W3` | `widget.serializeValue` direct assignment |
|
||||
| 11 | 4.61 | 1 788 | 1 | 1 | `S2.N12` | `nodeType.prototype.onConnectInput` patching |
|
||||
| 12 | 4.55 | 1 793 | 5 | 1 | `S6.A3` | `api.fetchApi` — extensions hit backend HTTP endpoints |
|
||||
|
||||
The top three pattern categories — graph mutation (`S11.G2`, `S10.D1`),
|
||||
prototype patching (`S2.*`), and the `app.graphToPrompt` god-object — together
|
||||
account for the majority of the blast radius and define the v2 API's
|
||||
non-negotiable compatibility surfaces.
|
||||
|
||||
## Refresh workflow
|
||||
|
||||
The database is curated by hand; the star cache and rollup are derived.
|
||||
|
||||
```bash
|
||||
# from this directory
|
||||
bash scripts/fetch-stars.sh # refresh GitHub stars (needs `gh` auth)
|
||||
python3 scripts/rollup-blast-radius.py # recompute touch-points-rollup.yaml
|
||||
```
|
||||
|
||||
To add new evidence or new patterns discovered during a future MCP
|
||||
code-search sweep, edit `scripts/add-evidence.py` (the inline `APPEND` and
|
||||
`NEW_PATTERNS` blocks are the source of truth for reproducibility) and run:
|
||||
|
||||
```bash
|
||||
python3 scripts/add-evidence.py
|
||||
python3 scripts/rollup-blast-radius.py
|
||||
```
|
||||
|
||||
## Source documents
|
||||
|
||||
The 52 patterns were derived from three primary inputs, then expanded by an
|
||||
MCP code-search sweep across 87 ecosystem repos:
|
||||
|
||||
1. **`AGENTS.md` §5** in this repo — 40+ repo callouts for contributor
|
||||
conventions and known extension surfaces.
|
||||
2. **[ADR 0008 — Entity Taxonomy](../decisions/0008-entity-taxonomy.md)** —
|
||||
defines the non-Node entity kinds (subgraphs, groups, reroutes, links)
|
||||
that drive surface family **S9**.
|
||||
3. **`widget-api-thoughts.md`** (in the cross-repo workspace) — the output
|
||||
system axis and widget lifecycle dependencies that drive surface family
|
||||
**S15** plus the lifecycle-coupling weight.
|
||||
|
||||
## Cross-references
|
||||
|
||||
This database is consumed by, and consumes, the rest of the ECS architecture
|
||||
docs:
|
||||
|
||||
- [`../ecs-target-architecture.md`](../ecs-target-architecture.md) — the
|
||||
target ECS shape this v2 API redesign serves
|
||||
- [`../ecs-world-command-api.md`](../ecs-world-command-api.md) — the World /
|
||||
Command API that v2 extensions will program against
|
||||
- [`../ecs-migration-plan.md`](../ecs-migration-plan.md) — how we get from
|
||||
today's monkey-patched LiteGraph to v2 + ECS
|
||||
- [`../ecs-lifecycle-scenarios.md`](../ecs-lifecycle-scenarios.md) — the
|
||||
lifecycle scenarios the test framework must cover (every touch-point row
|
||||
here ⇒ ≥1 scenario there)
|
||||
- [`../entity-interactions.md`](../entity-interactions.md) /
|
||||
[`../entity-problems.md`](../entity-problems.md) — the entity-model
|
||||
problems v2 must not perpetuate
|
||||
- [`../change-tracker.md`](../change-tracker.md) — the change-tracking
|
||||
contract that S11 (graph state) and S2 (`onSerialize`/`onDeserialize`
|
||||
patches) must remain compatible with
|
||||
265
docs/architecture/extension-api-v2/scripts/add-evidence-pass2.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-evidence-pass2.py — second MCP sweep. Appends evidence to under-evidenced
|
||||
# patterns and adds new patterns discovered in pass-2 (graph batching seam,
|
||||
# window.* globals, setDirtyCanvas redraw idiom).
|
||||
#
|
||||
# Idempotent: skips evidence already present (matched by repo+file+lines).
|
||||
#
|
||||
# Run: python3 scripts/add-evidence-pass2.py
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DB = ROOT / "research" / "touch-points" / "database.yaml"
|
||||
|
||||
|
||||
def url(repo: str, file: str, line: int) -> str:
|
||||
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
|
||||
|
||||
|
||||
def ev(repo, file, lines, **kw):
|
||||
e = {
|
||||
"repo": repo,
|
||||
"file": file,
|
||||
"lines": lines if isinstance(lines, list) else [lines],
|
||||
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
|
||||
}
|
||||
e.update(kw)
|
||||
return e
|
||||
|
||||
|
||||
# ─── Evidence to append to existing patterns ──────────────────────────────
|
||||
APPEND = {
|
||||
"S2.N17": [ # onSelected / onDeselected
|
||||
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider.js", 1, variant="prototype-patch", breakage_class="silent",
|
||||
notes="mxToolkit Slider patches onSelected for highlight state"),
|
||||
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider2D.js", 1, variant="prototype-patch", breakage_class="silent"),
|
||||
],
|
||||
"S2.N19": [ # onResize
|
||||
ev("SKBv0/ComfyUI_SKBundle", "js/MultiFloat.js", 1, variant="prototype-patch", breakage_class="silent",
|
||||
notes="MultiFloat widget syncs internal layout on resize"),
|
||||
ev("PGCRT/CRT-Nodes", "js/Magic_Lora_Loader.js", 1, variant="prototype-patch", breakage_class="silent"),
|
||||
ev("dorpxam/ComfyUI-LTX2-Microscope", "web/js/ui/visualizer.js", 1, variant="prototype-patch", breakage_class="silent",
|
||||
notes="visualizer reflows DOM widget on resize"),
|
||||
],
|
||||
"S9.R1": [ # Reroute manipulation
|
||||
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="graph.reroutes.values()", breakage_class="loud",
|
||||
notes="iterates reroute map directly — fork of frontend, but represents real internal contract surface"),
|
||||
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
|
||||
notes="external doc treats graph.reroutes as part of subgraph contract"),
|
||||
],
|
||||
"S9.SG1": [ # Set/Get virtual node
|
||||
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="virtual-node-companion", breakage_class="silent",
|
||||
notes="Flux Continuum hint system depends on Set/Get virtual node graph"),
|
||||
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1, variant="full-implementation",
|
||||
breakage_class="loud", notes="another SetInput/GetOutput pack — variant of KJNodes pattern"),
|
||||
],
|
||||
"S13.SC1": [ # ComfyNodeDef inspection
|
||||
ev("xeinherjer-dev/ComfyUI-XENodes", "web/js/combo_selector.js", 1, variant="nodeData.input.optional",
|
||||
breakage_class="silent", notes="reads nodeData.input.optional to drive UI generation"),
|
||||
ev("StableLlama/ComfyUI-basic_data_handling", "web/js/dynamicnode.js", 1, variant="nodeData.input.optional",
|
||||
breakage_class="silent"),
|
||||
ev("IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools", "js/sb_concat.js", 1, variant="nodeData.input.optional",
|
||||
breakage_class="silent"),
|
||||
ev("BennyKok/comfyui-deploy", "web-plugin/index.js", 1, variant="nodeData.input.required",
|
||||
breakage_class="silent", notes="comfyui-deploy is widely used; treats schema as a public contract"),
|
||||
ev("egormly/ComfyUI-EG_Tools", "web/dynamic_inputs.js", 1, variant="nodeData.input.optional",
|
||||
breakage_class="silent"),
|
||||
],
|
||||
"S3.C1": [ # LGraphCanvas.prototype.* monkey-patching — drawNodeShape variant
|
||||
ev("yolain/ComfyUI-Easy-Use-Frontend", "src/extensions/ui.js", 1, variant="drawNodeShape-patch",
|
||||
breakage_class="silent", notes="Easy-Use is a major pack; patches LGraphCanvas.prototype.drawNodeShape"),
|
||||
ev("melMass/comfy_mtb", "web/note_plus.js", 1, variant="canvas-draw-patch", breakage_class="silent",
|
||||
notes="comfy_mtb (popular pack) — note_plus draws decorations via canvas patching"),
|
||||
ev("lucafoscili/lf-nodes", "web/src/nodes/reroute.ts", 1, variant="onDrawForeground+canvas-draw",
|
||||
breakage_class="silent"),
|
||||
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 1, variant="onDrawForeground",
|
||||
breakage_class="silent"),
|
||||
],
|
||||
"S10.D2": [ # disconnectInput / disconnectOutput / connect
|
||||
ev("MockbaTheBorg/ComfyUI-Mockba", "js/slider.js", 1, variant="programmatic-disconnect",
|
||||
breakage_class="loud", notes="app.graph.getNodeById(tlink.target_id).disconnectInput(tlink.target_slot)"),
|
||||
ev("vjumpkung/comfyui-infinitetalk-native-sampler", "README.md", [1, 50], variant="documented-as-API",
|
||||
breakage_class="loud", notes="3rd-party docs treat node.disconnect* as a stable extension surface"),
|
||||
],
|
||||
"S8.P1": [ # isVirtualNode = true
|
||||
ev("ComfyNodePRs/PR-comfyui-pkg39-ccab78b5", "js/libs/image.js", [541, 1382], variant="filter-by-virtual",
|
||||
breakage_class="loud", notes="extension code filters nodes by isVirtualNode — treats it as discovery API"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Brand-new patterns discovered in pass-2 ──────────────────────────────
|
||||
NEW_PATTERNS = [
|
||||
{
|
||||
"pattern_id": "S11.G3",
|
||||
"surface_family": "S11",
|
||||
"surface": "graph.beforeChange / graph.afterChange — explicit batching seam for multi-step mutations",
|
||||
"fingerprint": "graph.beforeChange(); ...mutations...; graph.afterChange();",
|
||||
"semantic": (
|
||||
"extensions wrap multi-node/multi-link mutations in beforeChange/afterChange so undo, "
|
||||
"dirty-tracking, and re-render coalesce around the batch instead of per-mutation"
|
||||
),
|
||||
"v2_replacement": "world.batch(() => { ...mutations... }) — typed batching API",
|
||||
"decision_ref": (
|
||||
"First-class batching is required for any reactive layer that wants stable diffs; "
|
||||
"v2 should expose this as a mandatory wrapper for multi-mutation operations"
|
||||
),
|
||||
"test_target": "GRAPH_BATCH_BOUNDARY",
|
||||
"lifecycle_coupling": 1,
|
||||
"severity": "HIGH",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
|
||||
notes="docs use beforeChange/afterChange around subgraph promotion"),
|
||||
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="paste-undo-batch",
|
||||
breakage_class="loud", notes="paste flow batches mutations across clipboard restore"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"pattern_id": "S7.G1",
|
||||
"surface_family": "S7",
|
||||
"surface": "window.LiteGraph / window.comfyAPI.* — globals as public surface",
|
||||
"fingerprint": "window.LiteGraph.createNode(...); window.comfyAPI.app.app",
|
||||
"semantic": (
|
||||
"extensions reach into the global namespace for LiteGraph constructors/enums or for the "
|
||||
"module-as-global comfyAPI registry. This is the closest thing to a 'public ABI' today"
|
||||
),
|
||||
"v2_replacement": (
|
||||
"explicit `import { app, graph, LiteGraph } from '@comfy/extension'` + a typed registry "
|
||||
"keyed by extension name; window.* should remain as a deprecated read-only mirror"
|
||||
),
|
||||
"decision_ref": (
|
||||
"Cannot break window.LiteGraph immediately — too much ecosystem code reaches for it. "
|
||||
"Must ship typed import path first, then deprecate. Similar story to S11.G2 graph globals."
|
||||
),
|
||||
"test_target": "GLOBAL_NAMESPACE_COMPAT",
|
||||
"lifecycle_coupling": 0,
|
||||
"severity": "CRITICAL",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="window.LiteGraph",
|
||||
breakage_class="loud"),
|
||||
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1,
|
||||
variant="window.LiteGraph", breakage_class="loud"),
|
||||
ev("ArtHommage/HommageTools", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
|
||||
ev("PROJECTMAD/PROJECT-MAD-NODES", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
|
||||
ev("ryanontheinside/ComfyUI_RyanOnTheInside", "web/js/index.js", 1, variant="window.LiteGraph",
|
||||
breakage_class="loud"),
|
||||
ev("stavzszn/comfyui-teskors-utils", "web/js/index.js", 1, variant="window.LiteGraph",
|
||||
breakage_class="loud"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"pattern_id": "S11.G4",
|
||||
"surface_family": "S11",
|
||||
"surface": "graph.setDirtyCanvas(true, true) — imperative canvas-redraw trigger",
|
||||
"fingerprint": "node.graph?.setDirtyCanvas?.(true, true); app.graph.setDirtyCanvas(true, true);",
|
||||
"semantic": (
|
||||
"after any imperative mutation extensions call setDirtyCanvas to force a redraw — the "
|
||||
"ecosystem's de-facto 'reactivity flush' primitive. v2 reactivity should make this unnecessary"
|
||||
),
|
||||
"v2_replacement": (
|
||||
"implicit — reactive system schedules redraw automatically when tracked entity mutates. "
|
||||
"Provide an escape hatch `world.markDirty()` only for non-reactive third-party canvas use"
|
||||
),
|
||||
"decision_ref": (
|
||||
"Replacing this surface is the strongest evidence that v2 reactivity actually buys something. "
|
||||
"Should be in v2 'value proposition' demo extension"
|
||||
),
|
||||
"test_target": "REDRAW_NO_LONGER_NEEDED",
|
||||
"lifecycle_coupling": 0,
|
||||
"severity": "MEDIUM",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 111,
|
||||
variant="post-mutation-redraw", breakage_class="silent"),
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 285,
|
||||
variant="post-mutation-redraw", breakage_class="silent"),
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/ui/module_node_picker_node_factory.js", 189,
|
||||
variant="post-mutation-redraw", breakage_class="silent"),
|
||||
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", [776, 1087],
|
||||
variant="post-mutation-redraw", breakage_class="silent",
|
||||
notes="multiple call sites — extension assumes manual flush is the contract"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"pattern_id": "S10.D3",
|
||||
"surface_family": "S10",
|
||||
"surface": "node.setSize(node.computeSize()) — imperative resize after dynamic mutation",
|
||||
"fingerprint": "node.setSize?.(node.computeSize())",
|
||||
"semantic": (
|
||||
"after dynamic widget/input/output mutation, extensions manually call computeSize+setSize "
|
||||
"to reflow the node. Companion to S2.N11 (computeSize override) and S11.G4 (setDirtyCanvas)"
|
||||
),
|
||||
"v2_replacement": (
|
||||
"automatic — reactive layout system recomputes node size when widget/slot collection changes. "
|
||||
"Expose `nodeHandle.requestLayout()` only as escape hatch"
|
||||
),
|
||||
"decision_ref": "Pairs with S11.G4 — both are 'manual flush' idioms that v2 should obviate",
|
||||
"test_target": "AUTO_RELAYOUT_ON_MUTATION",
|
||||
"lifecycle_coupling": 0,
|
||||
"severity": "MEDIUM",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 283,
|
||||
variant="setSize+computeSize", breakage_class="silent",
|
||||
notes="exact 'node.setSize?.(node.computeSize())' canonical idiom"),
|
||||
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_node.js", 466,
|
||||
variant="manual-height", breakage_class="silent",
|
||||
notes="commented-out manual setSize — shows the pattern is well-known"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def normalize_evidence_key(e):
|
||||
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
|
||||
|
||||
|
||||
def main():
|
||||
db = yaml.safe_load(DB.read_text())
|
||||
|
||||
appended = 0
|
||||
skipped = 0
|
||||
for pid, new_evs in APPEND.items():
|
||||
for p in db["patterns"]:
|
||||
if p["pattern_id"] == pid:
|
||||
if "evidence" not in p or p["evidence"] is None:
|
||||
p["evidence"] = []
|
||||
existing = {normalize_evidence_key(e) for e in p["evidence"]}
|
||||
for e in new_evs:
|
||||
if normalize_evidence_key(e) in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
p["evidence"].append(e)
|
||||
appended += 1
|
||||
p["evidence_status"] = "swept"
|
||||
break
|
||||
else:
|
||||
print(f"⚠️ pattern {pid} not found")
|
||||
|
||||
added_new = 0
|
||||
existing_ids = {p["pattern_id"] for p in db["patterns"]}
|
||||
for np in NEW_PATTERNS:
|
||||
if np["pattern_id"] in existing_ids:
|
||||
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
|
||||
continue
|
||||
db["patterns"].append(np)
|
||||
added_new += 1
|
||||
|
||||
db["meta"]["patterns_count"] = len(db["patterns"])
|
||||
db["meta"]["sweep_status"] = "in-progress"
|
||||
if "evidence-sweep-pass-2" not in db["meta"].get("sweeps_done", []):
|
||||
db["meta"]["sweeps_done"].append("evidence-sweep-pass-2")
|
||||
|
||||
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
|
||||
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
|
||||
print(f"✅ added {added_new} new patterns")
|
||||
print(f"✅ DB now has {len(db['patterns'])} patterns")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
213
docs/architecture/extension-api-v2/scripts/add-evidence.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-evidence.py — append evidence to existing patterns and add NEW patterns
|
||||
# discovered during the MCP sweep. Idempotent: skips evidence already present
|
||||
# (matched by repo+file+lines).
|
||||
#
|
||||
# Run: python3 scripts/add-evidence.py
|
||||
#
|
||||
# Source-of-truth for evidence is inline below — keeping it in version
|
||||
# control makes the sweep reproducible and reviewable.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DB = ROOT / "touch-points-database.yaml"
|
||||
|
||||
|
||||
def url(repo: str, file: str, line: int) -> str:
|
||||
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
|
||||
|
||||
|
||||
def ev(repo, file, lines, **kw):
|
||||
e = {
|
||||
"repo": repo,
|
||||
"file": file,
|
||||
"lines": lines if isinstance(lines, list) else [lines],
|
||||
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
|
||||
}
|
||||
e.update(kw)
|
||||
return e
|
||||
|
||||
|
||||
# ─── Evidence to merge into existing patterns ─────────────────────────────
|
||||
APPEND = {
|
||||
"S2.N12": [
|
||||
# already has core dynamicWidgets entry
|
||||
],
|
||||
"S2.N13": [
|
||||
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_relay.js", [90, 92], variant="subclass-override", breakage_class="loud", notes="rgthree — major pack. Subclass override pattern (calls super)."),
|
||||
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_repeater.js", [21, 24], variant="subclass-override", breakage_class="loud"),
|
||||
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_relay.ts", [146, 153], variant="subclass-override-ts", breakage_class="loud"),
|
||||
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_repeater.ts", [46, 56], variant="subclass-override-ts", breakage_class="loud"),
|
||||
ev("rgthree/rgthree-comfy", "web/comfyui/base_any_input_connected_node.js", [136, 138], variant="subclass-override", breakage_class="loud"),
|
||||
],
|
||||
"S2.N14": [
|
||||
ev("niknah/presentation-ComfyUI", "js/PresentationDropDown.js", [12, 75], variant="prototype-chain", breakage_class="silent", notes="captures original onWidgetChanged via prototype chain"),
|
||||
ev("chyer/Chye-ComfyUI-Toolset", "web/comfyui/text_file_loader.js", [35, 115], variant="instance-method", breakage_class="silent"),
|
||||
],
|
||||
"S2.N15": [
|
||||
ev("Azornes/Comfyui-LayerForge", "js/CanvasView.js", 1438, variant="prototype-replace", breakage_class="silent", notes="LayerForge (313★) — replaces serialize wholesale"),
|
||||
ev("Azornes/Comfyui-LayerForge", "src/CanvasView.ts", 1657, variant="prototype-replace-ts", breakage_class="silent"),
|
||||
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_wan_motion_presets.js", 598, variant="prototype-replace", breakage_class="silent"),
|
||||
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_ltx2_extension_presets.js", 350, variant="prototype-replace", breakage_class="silent"),
|
||||
ev("DazzleNodes/ComfyUI-Smart-Resolution-Calc", "web/utils/serialization.js", 32, variant="prototype-replace", breakage_class="silent"),
|
||||
ev("alankent/ComfyUI-OA-360-Clip", "web/oa_360_clip.js", 900, variant="prototype-replace", breakage_class="silent"),
|
||||
],
|
||||
"S2.N16": [
|
||||
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 328, variant="push", breakage_class="silent", notes="extension pushes to node.widgets directly"),
|
||||
ev("max-dingsda/ComfyUI-AllinOne-LazyNode", "web/js/aio_core_preview.js", 170, variant="push", breakage_class="silent"),
|
||||
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-set-get.js", 9, variant="indexed-read", breakage_class="loud", notes="reads node.widgets[0].value to get name"),
|
||||
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-load-image.js", 56, variant="indexOf", breakage_class="loud"),
|
||||
ev("viswamohankomati/ComfyUI-Copilot", "ComfyUI/custom_nodes/ComfyUI-Copilot/ui/src/utils/comfyuiWorkflowApi2Ui.ts", [305, 316], variant="widgets_values-push", breakage_class="silent", notes="touches node.widgets_values, the serialized array"),
|
||||
],
|
||||
"S11.G1": [
|
||||
ev("FloyoAI/ComfyUI-SoundFlow", "js/PreviewAudio.js", 293, variant="post-mutation-bump", breakage_class="silent", notes="bumps version after node-internal mutation to trigger redraw"),
|
||||
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 84, variant="post-mutation-bump", breakage_class="silent"),
|
||||
ev("coeuskoalemoss/comfyUI-layerstyle-custom", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent"),
|
||||
ev("40740/ComfyUI_LayerStyle_Bmss", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent", notes="duplicate-of-coeuskoalemoss pattern — fork"),
|
||||
],
|
||||
"S11.G2": [
|
||||
ev("yolain/ComfyUI-Easy-Use", "web_version/v1/js/easy/easyExtraMenu.js", 439, variant="add+createNode", breakage_class="loud", notes="Easy-Use is a major pack; uses graph.add(LiteGraph.createNode(...))"),
|
||||
ev("KumihoIO/kumiho-plugins", "comfyui/web/js/kumiho.js", 431, variant="add+createNode", breakage_class="loud"),
|
||||
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-ui-enhancements.js", 29, variant="remove-then-add", breakage_class="loud", notes="swap nodes by remove+add — preserves layout via savedProps"),
|
||||
ev("Comfy-Org/ComfyUI_frontend", "browser_tests/tests/workflowPersistence.spec.ts", [351, 413], variant="add+createNode", breakage_class="loud", notes="OUR OWN E2E TESTS rely on window.app.graph.add(window.LiteGraph.createNode(...))"),
|
||||
],
|
||||
"S12.UI1": [
|
||||
ev("robertvoy/ComfyUI-Distributed", "web/main.js", [269, 270], variant="extensionManager.registerSidebarTab", breakage_class="loud", notes="real call site for sidebar registration"),
|
||||
ev("criskb/Comfypencil", "web/comfy_pencil_extension.js", [955, 956], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
|
||||
ev("maxi45274/ComfyUI_LinkFX", "js/LinkFX.js", [707, 709], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
|
||||
],
|
||||
"S10.D1": [
|
||||
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/node.js", [18, 53], variant="dynamic-addInput-loop", breakage_class="loud", notes="real-world dynamic input expansion: this.addInput('infix '+i,'STRING')"),
|
||||
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-mode-nodes.js", [42, 106], variant="virtual-node-setup", breakage_class="loud", notes="Eclipse uses addOutput within isVirtualNode setup"),
|
||||
ev("Comfy-Org/ComfyUI_frontend", "src/lib/litegraph/src/canvas/LinkConnector.core.test.ts", [121, 158], variant="OUR-TESTS", breakage_class="loud", notes="OUR OWN TESTS depend on addOutput"),
|
||||
],
|
||||
"S9.S1": [
|
||||
ev("lordwedggie/xcpNodes", "js/xcpDerpINT.js", 162, variant="output-color_on-assignment", breakage_class="silent", notes="this.outputs[0].color_on = templateSlotColorOn — direct slot visual override"),
|
||||
ev("nodetool-ai/nodetool", "subgraphs.md", [267, 299], variant="documented-pattern", breakage_class="loud", notes="external docs reference color_on for subgraph slot inheritance"),
|
||||
],
|
||||
"S4.W4": [
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", [24, 27], variant="includes-then-push", breakage_class="silent", notes="checks values then mutates"),
|
||||
ev("zzggi2024/shaobkj", "js/dynamic_inputs.js", [374, 376], variant="snapshot-then-mutate", breakage_class="silent", notes="saves __originalValues snapshot before mutating widget.options.values"),
|
||||
ev("EnragedAntelope/EA_LMStudio", "web/ea_lmstudio.js", 11, variant="documented-fallback", breakage_class="loud", notes="explicit comment: 'Legacy LiteGraph frontend: full support via widget.options.values'"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Brand-new patterns discovered during sweep ───────────────────────────
|
||||
NEW_PATTERNS = [
|
||||
{
|
||||
"pattern_id": "S6.A3",
|
||||
"surface_family": "S6",
|
||||
"surface": "api.fetchApi — extensions hit backend HTTP endpoints",
|
||||
"fingerprint": "await api.fetchApi('/upload/image', { method: 'POST', body: data })",
|
||||
"semantic": "extensions call ComfyAPI.fetchApi as the canonical way to reach backend HTTP routes (auth, base URL, error handling all handled)",
|
||||
"v2_replacement": "ctx.api.fetch(path, init) typed wrapper; same semantics, narrower surface",
|
||||
"decision_ref": "Pattern is widely used and CORRECT — keep contract, just type it",
|
||||
"test_target": "BACKEND_HTTP_CLIENT",
|
||||
"lifecycle_coupling": 0,
|
||||
"severity": "HIGH",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 54, variant="POST-multipart", breakage_class="loud"),
|
||||
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/api/module_node_picker_api.js", 43, variant="generic-wrapper", breakage_class="loud"),
|
||||
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", 1227, variant="POST-upload", breakage_class="loud"),
|
||||
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_window.js", 1379, variant="GET", breakage_class="loud"),
|
||||
ev("Comfy-Org/ComfyUI_frontend", "src/components/common/BackgroundImageUpload.vue", 61, variant="POST-upload", breakage_class="loud", notes="OUR OWN UI uses api.fetchApi for image upload"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"pattern_id": "S6.A4",
|
||||
"surface_family": "S6",
|
||||
"surface": "app.queuePrompt / app.api.queuePrompt patching or direct call",
|
||||
"fingerprint": "const orig = window.app.api.queuePrompt; window.app.api.queuePrompt = async function(...args) {...; return orig(...args)}",
|
||||
"semantic": "intercept or trigger workflow execution; auth tokens, custom payload mutation, sidebar 'Run' buttons",
|
||||
"v2_replacement": "graph.run({ batch }) explicit API + app.on('beforeRun', payload => mutate(payload))",
|
||||
"decision_ref": "Pairs with S6.A1 graphToPrompt as the OTHER half of the execute-pipeline interception story",
|
||||
"test_target": "PROMPT_QUEUE_INTERCEPT",
|
||||
"lifecycle_coupling": 2,
|
||||
"severity": "CRITICAL",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("gigici/ComfyUI_BlendPack", "js/ui/NodeUI.js", 99, variant="bind-then-replace", breakage_class="silent", notes="window.app.api.queuePrompt?.bind(window.app.api) — patches the API-level queue"),
|
||||
ev("MajoorWaldi/ComfyUI-Majoor-AssetsManager", "js/features/viewer/workflowSidebar/sidebarRunButton.js", [317, 321], variant="multi-path-fallback", breakage_class="loud", notes="documents 4 distinct invocation paths: app.api.queuePrompt, app.queuePrompt, fetch /prompt, etc."),
|
||||
ev("rohapa/comfyui-replay", "README.md", [497, 975], variant="call+fallback", breakage_class="loud", notes="app.queuePrompt(0,1) with raw fetch /prompt fallback"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"pattern_id": "S5.A3",
|
||||
"surface_family": "S5",
|
||||
"surface": "api.addEventListener('execution_start' | 'execution_success' | 'execution_error' | 'execution_cached' | 'executing' | 'status' | 'reconnecting')",
|
||||
"fingerprint": "api.addEventListener('execution_start', e => ...)",
|
||||
"semantic": "extensions subscribe to backend execution lifecycle WebSocket events",
|
||||
"v2_replacement": "ctx.execution.on('start' | 'success' | 'error' | 'cached', payload => ...) typed events",
|
||||
"decision_ref": "Cross-references S5.A1 (existence-proof of events-everywhere)",
|
||||
"test_target": "EXECUTION_LIFECYCLE_EVENTS",
|
||||
"lifecycle_coupling": 0,
|
||||
"severity": "HIGH",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [
|
||||
ev("zzw5516/ComfyUI-zw-tools", "entry/entry.js", [27, 28], variant="execution_start", breakage_class="loud"),
|
||||
ev("flymyd/koishi-plugin-comfyui-client", "src/ComfyUINode.ts", 109, variant="execution_start-case", breakage_class="loud"),
|
||||
ev("kyuz0/amd-strix-halo-comfyui-toolboxes", "scripts/benchmark_workflows.py", 52, variant="execution_start-message-type", breakage_class="loud"),
|
||||
ev("philippjbauer/devint25-comfyui-api-demo", "README.md", [144, 179], variant="documented-event-list", breakage_class="loud"),
|
||||
ev("philippjbauer/devint25-comfyui-api-demo", "Models/ComfyModels.cs", 159, variant="enum-of-event-names", breakage_class="loud", notes="C# wrapper enumerates the WebSocket event vocabulary as the public API"),
|
||||
ev("huafitwjb/ComfyUI-GO-Mobile-app", "app/src/main/java/com/example/myapplication/util/Constants.kt", 26, variant="execution_success-const", breakage_class="loud"),
|
||||
ev("hernantech/comfymcp", "src/comfymcp/client/types.py", 17, variant="execution_success-enum", breakage_class="loud"),
|
||||
ev("choovin/comfyui-api", "README.md", [57, 1945], variant="execution_success-doc", breakage_class="loud", notes="explicit 'Sidecar-like tracing' depending on execution_* events as public API"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def normalize_evidence_key(e):
|
||||
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
|
||||
|
||||
|
||||
def main():
|
||||
db = yaml.safe_load(DB.read_text())
|
||||
|
||||
appended = 0
|
||||
skipped = 0
|
||||
for pid, new_evs in APPEND.items():
|
||||
for p in db["patterns"]:
|
||||
if p["pattern_id"] == pid:
|
||||
existing = {normalize_evidence_key(e) for e in (p.get("evidence") or [])}
|
||||
if "evidence" not in p or p["evidence"] is None:
|
||||
p["evidence"] = []
|
||||
for e in new_evs:
|
||||
if normalize_evidence_key(e) in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
p["evidence"].append(e)
|
||||
appended += 1
|
||||
# Mark evidence_status as swept now that we've sourced real data
|
||||
p["evidence_status"] = "swept"
|
||||
break
|
||||
else:
|
||||
print(f"⚠️ pattern {pid} not found")
|
||||
|
||||
added_new = 0
|
||||
existing_ids = {p["pattern_id"] for p in db["patterns"]}
|
||||
for np in NEW_PATTERNS:
|
||||
if np["pattern_id"] in existing_ids:
|
||||
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
|
||||
continue
|
||||
db["patterns"].append(np)
|
||||
added_new += 1
|
||||
|
||||
db["meta"]["patterns_count"] = len(db["patterns"])
|
||||
db["meta"]["sweep_status"] = "in-progress"
|
||||
if "evidence-sweep-pass-1" not in db["meta"].get("sweeps_done", []):
|
||||
db["meta"]["sweeps_done"].append("evidence-sweep-pass-1")
|
||||
|
||||
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
|
||||
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
|
||||
print(f"✅ added {added_new} new patterns")
|
||||
print(f"✅ DB now has {len(db['patterns'])} patterns")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
docs/architecture/extension-api-v2/scripts/fetch-stars.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# fetch-stars.sh — populate research/touch-points/star-cache.yaml
|
||||
# Reads database.yaml, extracts unique repo: entries, queries gh api for stars.
|
||||
# Usage: bash scripts/fetch-stars.sh
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DB="$DIR/../touch-points-database.yaml"
|
||||
CACHE="$DIR/../touch-points-star-cache.yaml"
|
||||
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "❌ gh CLI not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract unique repo: entries from database
|
||||
repos=$(grep -E '^\s*-\s*repo:\s' "$DB" | sed -E 's/^\s*-\s*repo:\s*//' | sort -u | grep -v '^$' || true)
|
||||
|
||||
today=$(date +%Y-%m-%d)
|
||||
|
||||
{
|
||||
echo "# ───────────────────────────────────────────────────────────────────────"
|
||||
echo "# GitHub star cache for repos referenced in database.yaml"
|
||||
echo "# Refresh: bash scripts/fetch-stars.sh"
|
||||
echo "# Asof dates allow drift detection"
|
||||
echo "# ───────────────────────────────────────────────────────────────────────"
|
||||
echo ""
|
||||
echo "asof: $today"
|
||||
echo "populated_via: scripts/fetch-stars.sh"
|
||||
echo ""
|
||||
echo "repos:"
|
||||
} > "$CACHE.tmp"
|
||||
|
||||
count=0
|
||||
err_count=0
|
||||
for r in $repos; do
|
||||
count=$((count + 1))
|
||||
printf " [%3d] %s ... " "$count" "$r" >&2
|
||||
if data=$(gh api "repos/$r" 2>/dev/null); then
|
||||
stars=$(echo "$data" | jq -r '.stargazers_count')
|
||||
archived=$(echo "$data" | jq -r '.archived')
|
||||
forks=$(echo "$data" | jq -r '.forks_count')
|
||||
last=$(echo "$data" | jq -r '.pushed_at' | cut -dT -f1)
|
||||
echo "★ $stars" >&2
|
||||
{
|
||||
echo " - repo: $r"
|
||||
echo " stars: $stars"
|
||||
echo " archived: $archived"
|
||||
echo " forks: $forks"
|
||||
echo " last_commit: $last"
|
||||
echo " asof: $today"
|
||||
} >> "$CACHE.tmp"
|
||||
else
|
||||
err_count=$((err_count + 1))
|
||||
echo "ERROR" >&2
|
||||
{
|
||||
echo " - repo: $r"
|
||||
echo " stars: null"
|
||||
echo " error: \"gh api failed (rate limit / repo missing / network)\""
|
||||
echo " asof: $today"
|
||||
} >> "$CACHE.tmp"
|
||||
fi
|
||||
done
|
||||
|
||||
mv "$CACHE.tmp" "$CACHE"
|
||||
echo "" >&2
|
||||
echo "✅ Wrote $CACHE — $count repos, $err_count errors" >&2
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
# merge-staging-pass3.py — single-threaded merger for pass-3 staging files.
|
||||
#
|
||||
# Reads:
|
||||
# research/touch-points/staging/r8-evidence.yaml (clone-grep)
|
||||
# research/touch-points/staging/r9-security.yaml (security scan + proposed S16.* patterns)
|
||||
# research/touch-points/staging/r9-guides.yaml (sanctioned surfaces from docs we ship)
|
||||
# research/touch-points/staging/r9-cookiecutter.yaml (scaffolded = forced-public surfaces)
|
||||
#
|
||||
# Writes back to:
|
||||
# research/touch-points/database.yaml
|
||||
#
|
||||
# Safe to re-run; per-(repo, file, lines) dedup is enforced.
|
||||
# R8 evidence is capped at 6 rows per pattern (already capped per repo+pattern in producer).
|
||||
#
|
||||
# R9.popularity is metadata about repos, not evidence — skipped here.
|
||||
# R9.qa is regression-scenario seeds for I-TF.3 — referenced but not merged into DB.
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DB = ROOT / "research" / "touch-points" / "database.yaml"
|
||||
STAGING = ROOT / "research" / "touch-points" / "staging"
|
||||
|
||||
R8 = STAGING / "r8-evidence.yaml"
|
||||
R9_SEC = STAGING / "r9-security.yaml"
|
||||
R9_GUIDES = STAGING / "r9-guides.yaml"
|
||||
R9_CK = STAGING / "r9-cookiecutter.yaml"
|
||||
|
||||
CAP_PER_PATTERN_FROM_R8 = 8 # adjust if DB explodes
|
||||
|
||||
|
||||
def normalize_lines(lines):
|
||||
if isinstance(lines, str):
|
||||
# R8 emitted strings like "[119, 131]" — convert
|
||||
try:
|
||||
return tuple(eval(lines, {"__builtins__": {}}, {}))
|
||||
except Exception:
|
||||
return (lines,)
|
||||
if isinstance(lines, list):
|
||||
return tuple(lines)
|
||||
return (lines,)
|
||||
|
||||
|
||||
def evkey(e):
|
||||
return (e.get("repo"), e.get("file"), normalize_lines(e.get("lines")))
|
||||
|
||||
|
||||
def append_dedup(target_evidence, new_rows, cap=None):
|
||||
existing = {evkey(e) for e in target_evidence}
|
||||
appended = 0
|
||||
skipped = 0
|
||||
rows_to_consider = list(new_rows)
|
||||
if cap and len(rows_to_consider) > cap:
|
||||
# Prefer rows from higher-star repos when capping.
|
||||
# Order is producer-defined; keep first `cap`.
|
||||
rows_to_consider = rows_to_consider[:cap]
|
||||
for e in rows_to_consider:
|
||||
# Normalize line representation
|
||||
if isinstance(e.get("lines"), str):
|
||||
e["lines"] = list(normalize_lines(e["lines"]))
|
||||
if evkey(e) in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
target_evidence.append(e)
|
||||
existing.add(evkey(e))
|
||||
appended += 1
|
||||
return appended, skipped
|
||||
|
||||
|
||||
def main():
|
||||
db = yaml.safe_load(DB.read_text())
|
||||
patterns_by_id = {p["pattern_id"]: p for p in db["patterns"]}
|
||||
|
||||
total_appended = 0
|
||||
total_skipped = 0
|
||||
new_patterns_added = 0
|
||||
|
||||
# ─── R8 (clone-grep) ────────────────────────────────────────────
|
||||
r8 = yaml.safe_load(R8.read_text())
|
||||
print(f"R8: {sum(len(v) for v in r8.values())} total rows across {len(r8)} patterns")
|
||||
for pid, rows in r8.items():
|
||||
if pid not in patterns_by_id:
|
||||
print(f" ⚠️ R8 pattern {pid} not in DB — skipping")
|
||||
continue
|
||||
p = patterns_by_id[pid]
|
||||
if "evidence" not in p or p["evidence"] is None:
|
||||
p["evidence"] = []
|
||||
a, s = append_dedup(p["evidence"], rows, cap=CAP_PER_PATTERN_FROM_R8)
|
||||
total_appended += a
|
||||
total_skipped += s
|
||||
p["evidence_status"] = "swept"
|
||||
|
||||
# ─── R9.security: proposed S16.* patterns ───────────────────────
|
||||
sec = yaml.safe_load(R9_SEC.read_text())
|
||||
for sp in sec.get("proposed_patterns", []):
|
||||
pid = sp.get("proposed_pattern_id")
|
||||
if not pid:
|
||||
continue
|
||||
if pid in patterns_by_id:
|
||||
print(f" R9.sec pattern {pid} already exists — appending evidence only")
|
||||
target = patterns_by_id[pid]
|
||||
else:
|
||||
# Materialize the new pattern
|
||||
new_p = {
|
||||
"pattern_id": pid,
|
||||
"surface_family": sp.get("surface_family", "S16"),
|
||||
"surface": sp.get("surface", ""),
|
||||
"fingerprint": sp.get("fingerprint", ""),
|
||||
"semantic": sp.get("semantic", ""),
|
||||
"v2_replacement": sp.get("v2_replacement", ""),
|
||||
"decision_ref": sp.get("rationale", ""),
|
||||
"test_target": sp.get("test_target", ""),
|
||||
"lifecycle_coupling": 0,
|
||||
"severity": "MEDIUM",
|
||||
"evidence_status": "swept",
|
||||
"evidence": [],
|
||||
}
|
||||
db["patterns"].append(new_p)
|
||||
patterns_by_id[pid] = new_p
|
||||
target = new_p
|
||||
new_patterns_added += 1
|
||||
print(f" ➕ R9.sec NEW pattern {pid}: {sp.get('surface', '')[:60]}")
|
||||
|
||||
# Materialize evidence rows from R9.sec
|
||||
evidence_field = sp.get("evidence")
|
||||
if isinstance(evidence_field, str):
|
||||
try:
|
||||
evidence_field = eval(evidence_field, {"__builtins__": {}}, {})
|
||||
except Exception:
|
||||
evidence_field = []
|
||||
if not isinstance(evidence_field, list):
|
||||
evidence_field = []
|
||||
rows = []
|
||||
for e in evidence_field:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
rows.append({
|
||||
"pattern_id": pid,
|
||||
"repo": e.get("repo", "unknown"),
|
||||
"file": e.get("file", "unknown"),
|
||||
"lines": e.get("lines", [1]) if isinstance(e.get("lines"), (list, int)) else [1],
|
||||
"url": e.get("url", ""),
|
||||
"rule": e.get("rule", ""),
|
||||
"source": "security",
|
||||
"variant": e.get("rule", "yara/bandit-hit"),
|
||||
})
|
||||
a, s = append_dedup(target["evidence"], rows)
|
||||
total_appended += a
|
||||
total_skipped += s
|
||||
|
||||
# ─── R9.cookiecutter: scaffolded surfaces ───────────────────────
|
||||
ck = yaml.safe_load(R9_CK.read_text())
|
||||
for entry in ck.get("scaffold_surfaces", []):
|
||||
pid = entry.get("pattern_id")
|
||||
if not pid or pid not in patterns_by_id:
|
||||
continue
|
||||
target = patterns_by_id[pid]
|
||||
if "evidence" not in target or target["evidence"] is None:
|
||||
target["evidence"] = []
|
||||
rows = [{
|
||||
"pattern_id": pid,
|
||||
"repo": "cookiecutter-comfy-extension",
|
||||
"file": entry.get("template_file", "unknown"),
|
||||
"lines": entry.get("lines", [1]),
|
||||
"url": "",
|
||||
"source": "cookiecutter",
|
||||
"variant": "scaffolded-by-default",
|
||||
"excerpt": entry.get("excerpt", ""),
|
||||
"notes": "FORCED-PUBLIC: this surface is generated by the default scaffold, so v2 cannot break it without breaking new-extension onboarding",
|
||||
}]
|
||||
a, s = append_dedup(target["evidence"], rows)
|
||||
total_appended += a
|
||||
total_skipped += s
|
||||
|
||||
# ─── R9.guides: surfaces we teach in docs ───────────────────────
|
||||
guides = yaml.safe_load(R9_GUIDES.read_text())
|
||||
for entry in guides.get("sanctioned_surfaces", []):
|
||||
pid = entry.get("pattern_id")
|
||||
if not pid or pid not in patterns_by_id:
|
||||
continue
|
||||
target = patterns_by_id[pid]
|
||||
if "evidence" not in target or target["evidence"] is None:
|
||||
target["evidence"] = []
|
||||
rows = [{
|
||||
"pattern_id": pid,
|
||||
"repo": "comfyanonymous/custom-nodes-guides",
|
||||
"file": entry.get("taught_in", "unknown"),
|
||||
"lines": entry.get("lines", [1]),
|
||||
"url": "",
|
||||
"source": "guides",
|
||||
"variant": "taught-in-official-docs",
|
||||
"excerpt": entry.get("excerpt", ""),
|
||||
"notes": "SANCTIONED-PUBLIC: this surface is taught in official docs we ship, so v2 must keep it stable",
|
||||
}]
|
||||
a, s = append_dedup(target["evidence"], rows)
|
||||
total_appended += a
|
||||
total_skipped += s
|
||||
|
||||
# ─── Update meta ────────────────────────────────────────────────
|
||||
db["meta"]["patterns_count"] = len(db["patterns"])
|
||||
db["meta"]["sweep_status"] = "in-progress"
|
||||
sweeps = db["meta"].setdefault("sweeps_done", [])
|
||||
if "evidence-sweep-pass-3" not in sweeps:
|
||||
sweeps.append("evidence-sweep-pass-3")
|
||||
|
||||
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
|
||||
|
||||
total_evidence = sum(len(p.get("evidence") or []) for p in db["patterns"])
|
||||
print()
|
||||
print(f"✅ appended {total_appended} rows ({total_skipped} dupes skipped)")
|
||||
print(f"✅ added {new_patterns_added} new patterns")
|
||||
print(f"✅ DB now: {len(db['patterns'])} patterns, {total_evidence} evidence rows")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
# rollup-blast-radius.py — compute per-pattern blast-radius metrics from
|
||||
# database.yaml + star-cache.yaml, write to research/touch-points/rollup.yaml.
|
||||
#
|
||||
# Blast-radius formula (per PLAN.md):
|
||||
# br = (log10(1 + cumulative_stars)) * w_stars (default 1.0)
|
||||
# + (log10(1 + occurrence_count)) * w_occ (default 0.7)
|
||||
# + (signature_count - 1) * w_sig (default 0.5)
|
||||
# + silent_breakage_weight * w_silent (default 0.5)
|
||||
# + lifecycle_coupling_weight * w_lifecycle (default 0.4)
|
||||
#
|
||||
# silent_breakage_weight & lifecycle_coupling_weight come from the per-pattern
|
||||
# heuristics field; if absent they default to 0.
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DB = ROOT / "touch-points-database.yaml"
|
||||
STARS = ROOT / "touch-points-star-cache.yaml"
|
||||
OUT = ROOT / "touch-points-rollup.yaml"
|
||||
|
||||
W = {
|
||||
"stars": 1.0,
|
||||
"occ": 0.7,
|
||||
"sig": 0.5,
|
||||
"silent": 0.5,
|
||||
"lifecycle": 0.4,
|
||||
}
|
||||
|
||||
|
||||
def load_stars() -> dict[str, int]:
|
||||
if not STARS.exists():
|
||||
return {}
|
||||
cache = yaml.safe_load(STARS.read_text())
|
||||
out = {}
|
||||
for r in cache.get("repos", []) or []:
|
||||
if r.get("stars") is not None:
|
||||
out[r["repo"]] = int(r["stars"])
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
db = yaml.safe_load(DB.read_text())
|
||||
stars = load_stars()
|
||||
|
||||
rows = []
|
||||
for p in db.get("patterns", []) or []:
|
||||
evidence = p.get("evidence") or []
|
||||
repos = []
|
||||
for e in evidence:
|
||||
r = e.get("repo")
|
||||
if r:
|
||||
repos.append(r)
|
||||
unique_repos = sorted(set(repos))
|
||||
cum_stars = sum(stars.get(r, 0) for r in unique_repos)
|
||||
occ = len(evidence)
|
||||
sig_count = p.get("signature_count") or len(p.get("signatures") or []) or 1
|
||||
|
||||
# Pattern fields can be top-level or under 'heuristics'
|
||||
h = p.get("heuristics") or {}
|
||||
sev_map = {"CRITICAL": 2, "HIGH": 1.5, "MEDIUM": 1, "LOW": 0.5}
|
||||
silent_w = float(h.get("silent_breakage", sev_map.get(p.get("severity", ""), 0)))
|
||||
life_w = float(h.get("lifecycle_coupling", p.get("lifecycle_coupling", 0)))
|
||||
|
||||
br = (
|
||||
math.log10(1 + cum_stars) * W["stars"]
|
||||
+ math.log10(1 + occ) * W["occ"]
|
||||
+ max(0, sig_count - 1) * W["sig"]
|
||||
+ silent_w * W["silent"]
|
||||
+ life_w * W["lifecycle"]
|
||||
)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"pattern_id": p["pattern_id"],
|
||||
"surface_family": p.get("surface_family"),
|
||||
"name": p.get("name") or p.get("surface") or p.get("semantic_intent") or p.get("semantic"),
|
||||
"occurrences": occ,
|
||||
"unique_repos": len(unique_repos),
|
||||
"cumulative_stars": cum_stars,
|
||||
"signature_count": sig_count,
|
||||
"silent_breakage": silent_w,
|
||||
"lifecycle_coupling": life_w,
|
||||
"blast_radius": round(br, 3),
|
||||
"top_repos": [
|
||||
{"repo": r, "stars": stars.get(r, 0)}
|
||||
for r in sorted(unique_repos, key=lambda x: -stars.get(x, 0))[:5]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: -r["blast_radius"])
|
||||
|
||||
out = {
|
||||
"meta": {
|
||||
"generated_from": ["database.yaml", "star-cache.yaml"],
|
||||
"weights": W,
|
||||
"patterns_count": len(rows),
|
||||
},
|
||||
"patterns": rows,
|
||||
}
|
||||
OUT.write_text(yaml.safe_dump(out, sort_keys=False, width=120))
|
||||
print(f"✅ wrote {OUT.relative_to(ROOT)} ({len(rows)} patterns)")
|
||||
|
||||
print()
|
||||
print("Top 12 by blast radius:")
|
||||
print(f" {'rank':>4} {'br':>6} {'★sum':>6} {'occ':>3} {'sig':>3} pattern")
|
||||
for i, r in enumerate(rows[:12], 1):
|
||||
print(
|
||||
f" {i:>4} {r['blast_radius']:>6.2f} {r['cumulative_stars']:>6} "
|
||||
f"{r['occurrences']:>3} {r['signature_count']:>3} {r['pattern_id']} {r['name']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
15118
docs/architecture/extension-api-v2/touch-points-database.yaml
Normal file
313
docs/architecture/extension-api-v2/touch-points-plan.md
Normal file
@@ -0,0 +1,313 @@
|
||||
---
|
||||
source: in-house (no external URL — synthesized from R4 + database.yaml + meeting transcript)
|
||||
date_accessed: 2026-05-06
|
||||
created: 2026-05-06
|
||||
purpose: Plan + schema for the canonical touch-point database
|
||||
status: active
|
||||
---
|
||||
|
||||
# Touch-Point Database — Plan
|
||||
|
||||
## Why we are building this
|
||||
|
||||
The v2 extension API redesign (P1, D3.x) and the eventual test framework need a **shared evidence layer**: every API surface that real-world extensions touch, frequency-weighted by usage, with citations to verify. Without this, two failure modes are guaranteed:
|
||||
|
||||
1. **Silent regressions in v2.** Surfaces we don't know about can't be re-implemented or formally deprecated. The v2 service ships, big custom-node packs break, ComfyUI looks unstable.
|
||||
2. **Test framework with the wrong floor.** Tests that don't reflect real extension shapes will pass v2 while production extensions break.
|
||||
|
||||
The database is the input for:
|
||||
- v2 API gap analysis (D4 G1–G13, plus future Gs surfaced here)
|
||||
- Test framework design (widget-api-thoughts.md "Test Framework" section): every entry maps to ≥1 test case
|
||||
- Migration guide writing (P3, DEP3, DEP4)
|
||||
- "What can we actually delete" decisions (e.g., R4 found `loadedGraphNode` has 1 real call site)
|
||||
|
||||
## What the v2 POC shipped (CONTEXT for the audit)
|
||||
|
||||
There are 5 untracked v2 files in `ComfyUI_frontend` worktree (proof-of-concept):
|
||||
|
||||
- `src/types/extensionV2.ts` — `NodeHandle`, `WidgetHandle`, `defineNodeExtension`, `defineWidgetExtension` interfaces
|
||||
- `src/services/extensionV2Service.ts` — scope registry, reactive mount system, handle factories (with inline open-question comments)
|
||||
- `src/extensions/core/dynamicPrompts.v2.ts` — POC migration
|
||||
- `src/extensions/core/imageCrop.v2.ts` — POC migration (13→12 lines)
|
||||
- `src/extensions/core/previewAny.v2.ts` — POC migration (90→35 lines)
|
||||
|
||||
**Open questions left in v2 service comments** (touch-points must answer these):
|
||||
- `setLabel` — special vs just an option? `setHidden` — same?
|
||||
- `on('change')` watches `WidgetValue.value` only — how do extensions watch options/props?
|
||||
- `setSerializeValue` callback — should be `on('serialize')` or `onBeforeSerialize`?
|
||||
- Get/set vs getters/setters — should NodeHandle expose `get pos()` accessors?
|
||||
- `getProperties` — current `properties` bag is heavily used by extensions for "persist across teardown"; v2 must verify that pattern still works
|
||||
- `addWidget` returns by what mechanism? sync dispatch? promise?
|
||||
- Widget figler tree / coverage report of "strangler-figged vs re-implemented vs unsupported"
|
||||
|
||||
These open questions become *test cases*: for each, the database tells us how many extensions in the wild touch the underlying surface.
|
||||
|
||||
## Comprehensive surface enumeration
|
||||
|
||||
The audit covers **8 surface families**. Each family contains specific patterns to search for.
|
||||
|
||||
### S1 — `ComfyExtension` lifecycle hooks (17 hooks)
|
||||
|
||||
From `src/types/comfy.ts`, lines 144-266:
|
||||
|
||||
| Hook | Core extension files using it | Replacement direction |
|
||||
|---|---:|---|
|
||||
| `init` | 16 | unchanged in v2 (ExtensionOptions.init) |
|
||||
| `setup` | 3 | unchanged in v2 (ExtensionOptions.setup) |
|
||||
| `addCustomNodeDefs` | 1 | unknown — may need v2 registration API |
|
||||
| `getCustomWidgets` | 4 | replaced by `defineWidgetExtension` |
|
||||
| `beforeRegisterNodeDef` | 10 | replaced by `nodeTypes` filter + `inspectNodeDef` (G1) |
|
||||
| `beforeRegisterVueAppNodeDefs` | 0 | candidate for removal |
|
||||
| `registerCustomNodes` | 3 | NO v2 equivalent (D4-G2 BLOCKER) |
|
||||
| `loadedGraphNode` | 0 (core), 1 (entire wild corpus) | candidate for removal |
|
||||
| `nodeCreated` | 12 | `defineNodeExtension({ nodeCreated })` |
|
||||
| `beforeConfigureGraph` | 1 | needs decision — graph lifecycle hook |
|
||||
| `afterConfigureGraph` | 0 | candidate for removal |
|
||||
| `getSelectionToolboxCommands` | 0 | candidate for removal |
|
||||
| `getCanvasMenuItems` | 4 | EXISTS — replaces canvas right-click monkey-patching |
|
||||
| `getNodeMenuItems` | 4 | EXISTS — replaces node right-click monkey-patching (P6 in R4) |
|
||||
| `onAuthUserResolved` | 1 | unchanged |
|
||||
| `onAuthTokenRefreshed` | 1 | unchanged |
|
||||
| `onAuthUserLogout` | 1 | unchanged |
|
||||
|
||||
### S2 — `LGraphNode.prototype` methods commonly patched
|
||||
|
||||
Already-confirmed (R4): `onNodeCreated`, `onExecuted`, `onConnectionsChange`, `onRemoved`, `getExtraMenuOptions`, `convertWidgetToInput`, `onGraphConfigured`, `onConfigure`, `onInputDblClick`.
|
||||
|
||||
Add to search: `onAdded`, `onSerialize`, `onDeserialize`, `onDrawForeground`, `onDrawBackground`, `onSelected`, `onDeselected`, `onMouseDown`, `onMouseEnter`, `onMouseLeave`, `onDblClick`, `onPropertyChanged`, `onWidgetChanged`, `onResize`, `onAction`, `onConnectInput`, `onConnectOutput`, `onConfigure`, `onWorkflowConfigure`, `onConnectionsChange`, `onConfigure`, `onCreate`, `clone`, `computeSize`.
|
||||
|
||||
### S3 — `LGraphCanvas.prototype` methods commonly patched
|
||||
|
||||
Confirmed (R4 P7): `processKey`, `processContextMenu`, `computeVisibleNodes`. Our own core: `processMouseDown`, `processMouseMove` (simpleTouchSupport.ts).
|
||||
|
||||
Add to search: `drawNode`, `drawNodeShape`, `drawConnections`, `onMouseDown`, `onDblClick`, `getCanvasMenuOptions`, `getNodeMenuOptions`, `getGroupMenuOptions`, `processNodeWidgets`, `selectNodes`, `deselectAllNodes`, `setSelectedNodes`.
|
||||
|
||||
### S4 — Widget-level patterns (the heart of widget-api-thoughts.md)
|
||||
|
||||
- `.callback` chaining (R4 P1) — the dominant value-change pattern
|
||||
- `.value` direct reads/writes (R4 evidence: imageCompare, widgetInputs, customWidgets, saveImageExtraOutput)
|
||||
- `.serializeValue` assignment (dynamicPrompts.v2 uses it)
|
||||
- `.options.*` direct mutation
|
||||
- `.computedHeight`, `.y`, `.last_y` — layout-level reads
|
||||
- `.options.values` — combo widget values
|
||||
- `.options.serialize`, `.options.hidden`, `.options.readonly` — option flags
|
||||
- Custom widget types declared via `getCustomWidgets`
|
||||
- `addDOMWidget(name, type, element, options)` — DOM widget contribution (R4 P9)
|
||||
|
||||
**Widget thoughts file flags lifecycle dependencies** (widget-api-thoughts.md:25-30):
|
||||
- 3D widgets: file uploads
|
||||
- Webcam widgets: heavy perf
|
||||
- Webcam widgets: lifecycle-dependent serialization
|
||||
- Widgets whose post-serialize value depends on lifecycle steps
|
||||
|
||||
These need explicit DB entries with `lifecycle_dependent: true` flag.
|
||||
|
||||
### S5 — `ComfyApi` / `app.api` event surfaces
|
||||
|
||||
Confirmed (R4 P8): `addEventListener('executed', …)`, custom `'extName.eventName'` events.
|
||||
|
||||
Add to search: `addEventListener('executing', …)`, `'progress'`, `'progress_state'`, `'status'`, `'reconnecting'`, `'reconnected'`, `'execution_start'`, `'execution_success'`, `'execution_error'`, `'execution_cached'`, `'b_preview'`, `'logs'`.
|
||||
|
||||
### S6 — `ComfyApp` god-object touch points
|
||||
|
||||
- `app.graph` — direct LiteGraph object access
|
||||
- `app.canvas` — direct LGraphCanvas access
|
||||
- `app.canvasManager` — newer wrapper
|
||||
- `app.queuePrompt` — submit a workflow
|
||||
- `app.graphToPrompt` — serialize current graph to API payload
|
||||
- `app.loadGraphData` — load a workflow JSON
|
||||
- `app.extensionManager` — ExtensionManager registry access
|
||||
- `app.api` — see S5
|
||||
- `app.getNodeDefs` — node definition registry
|
||||
- `app.registerExtension` — the entry point itself
|
||||
- `app.ui` — legacy UI shim
|
||||
|
||||
### S7 — Window / global escape hatches
|
||||
|
||||
- `window.app` — escape hatch documented in index.ts
|
||||
- `window.graph` — escape hatch documented in index.ts
|
||||
- `window.LiteGraph` — direct LiteGraph access
|
||||
- `window.LGraphCanvas` — direct canvas class access
|
||||
- `window.comfyAPI.modules[...]` — production-only shim mechanism (per extension-development-guide.md)
|
||||
|
||||
### S8 — Special node properties (magic flags)
|
||||
|
||||
- `nodeType.prototype.isVirtualNode` (R4 P10) — virtual node flag
|
||||
- `nodeType.prototype.serialize_widgets` — serialization toggle
|
||||
- `nodeType.prototype.color`, `bgcolor` — visual override
|
||||
- `nodeType.prototype.shape` — node shape override
|
||||
- `nodeType['@<input>']` — input-type metadata (Eclipse pattern)
|
||||
- `nodeType.category` — menu category override
|
||||
|
||||
### S9 — Non-Node entity kinds (per ADR 0008)
|
||||
|
||||
ADR 0008 enumerates **six** entity kinds; the bulk of the ecosystem touches more than just `Node` and `Widget`. These touch points are largely undocumented in the v1 extension API.
|
||||
|
||||
- **Reroute** (`Reroute`, `RerouteId`) — `LiteGraph.createRerouteOnLink`, `graph.reroutes`, `node.connectByRerouteId`
|
||||
- **Group** (`LGraphGroup`) — `graph.groups`, `group.color`, `group.font`, `group.font_size`, `group.children`
|
||||
- **Link** (`LLink`, `LinkId`) — `link.color`, `link._pos`, `link._dragging`, `link.data`
|
||||
- **Slot** (`SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) — `slot.color_on/_off`, `slot.shape`, `slot.dir`, `slot.localized_name`
|
||||
- **Subgraph virtual nodes** — set/get virtual node trick (KJNodes), `nodeType.isVirtualNode = true` (S8) coupled with `graphToPrompt` rewriting (S6.A1)
|
||||
|
||||
### S10 — Dynamic node API (slot/connection mutation at runtime)
|
||||
|
||||
- `node.addInput(name, type)` / `node.removeInput(slot)` — runtime input mutation (typically inside `onConnectionsChange`)
|
||||
- `node.addOutput(name, type)` / `node.removeOutput(slot)` — runtime output mutation
|
||||
- `node.connect(srcSlot, target, dstSlot)` / `node.disconnectInput(slot)` / `node.disconnectOutput(slot)` — programmatic linking
|
||||
- `node.findOutputSlot(name)` / `node.findInputSlot(name)` — slot lookup by name
|
||||
- `node.setDirtyCanvas(true, true)` — force redraw (extremely common after any mutation)
|
||||
- `node.collapse()` / `node.setSize([w,h])` — imperative geometry
|
||||
|
||||
### S11 — Graph-level state and change-tracking
|
||||
|
||||
- `graph._version++` and `graph._version` reads — change-tracking signal **(project AGENTS.md §5: affects 40+ repos)**
|
||||
- `graph.add(node)` / `graph.remove(node)` / `graph.findNodesByType(type)` / `graph.findNodeById(id)`
|
||||
- `graph.serialize()` / `graph.configure(json)` — full-graph serialization (related to S6.A1 graphToPrompt but distinct)
|
||||
- `graph.beforeChange()` / `graph.afterChange()` — explicit batching seam
|
||||
- `graph.onNodeAdded` / `graph.onNodeRemoved` / `graph.onNodeConnectionChange` — graph-level callbacks (vs per-node)
|
||||
|
||||
### S12 — Shell UI registries (sidebar / bottom panel / commands / toasts)
|
||||
|
||||
These are *declarative* surfaces in v1 (extensions push registrations) but their semantics are still public API. Migration must preserve names and contracts.
|
||||
|
||||
- `extensionManager.registerSidebarTab(...)` — `SidebarTabExtension`
|
||||
- `extensionManager.registerBottomPanelTab(...)` — `BottomPanelExtension`
|
||||
- `commandManager.registerCommand(...)` — `CommandManager`
|
||||
- `toastManager.add(...)` / `toastManager.remove(...)` — `ToastManager`
|
||||
- `app.registerExtension({ settings: [...] })` — Settings system contributions
|
||||
- `app.registerExtension({ keybindings: [...] })` — Keybinding contributions
|
||||
- `app.registerExtension({ commands: [...], menuCommands: [...] })` — Menu/command contributions
|
||||
|
||||
### S13 — Schema interpretation (`ComfyNodeDef` / `InputSpec`)
|
||||
|
||||
Extensions inspect the node-def schema directly to drive UI/behavior — this is a public API by accident.
|
||||
|
||||
- `nodeData.input.required` / `nodeData.input.optional` / `nodeData.input.hidden` — input bag inspection
|
||||
- `nodeData.output[]` / `nodeData.output_name[]` / `nodeData.output_is_list[]` — output schema inspection
|
||||
- `nodeData.output_node` — special "output node" boolean flag
|
||||
- `nodeData.category` / `nodeData.python_module` — origin metadata
|
||||
- `InputSpec` sentinel objects — `["INT", { default, min, max, step }]`, `["STRING", { multiline }]`, `["COMBO", { values, default }]`, `["IMAGEUPLOAD", {...}]`, etc.
|
||||
|
||||
### S14 — Identity / Locator scheme
|
||||
|
||||
- `NodeLocatorId` — encodes `(graphScope, nodeId)` for cross-subgraph references
|
||||
- `NodeExecutionId` — backend execution-graph identifier
|
||||
- `parseNodeLocatorId` / `createNodeLocatorId` / `isNodeLocatorId` — public helpers exported from `src/types/index.ts`
|
||||
- Implicit pattern: extensions resolve "node X in subgraph Y" — must work after subgraph promotion
|
||||
|
||||
### S15 — Output system (per `widget-api-thoughts.md`)
|
||||
|
||||
`widget-api-thoughts.md` flags this as a separate change axis from widgets:
|
||||
|
||||
- Dynamic output mutation via `node.addOutput` / `node.removeOutput` (cross-references S10)
|
||||
- Schema-declared outputs (preferred end-state) — `OUTPUT_TYPES`-style explicit declaration
|
||||
- `nodeData.output_node` flag — node is a terminal/sink
|
||||
- `node.onExecuted({ images: [...] })` — output-display pattern (cross-references S2.N2)
|
||||
- "Force declaration" goal: extensions must declare output types in the node schema, not mutate at runtime
|
||||
|
||||
## Database schema
|
||||
|
||||
Each entry is a YAML record:
|
||||
|
||||
```yaml
|
||||
- pattern_id: P1.1 # stable ID for cross-reference
|
||||
surface_family: S4 # S1-S8
|
||||
surface: "widget.callback assignment" # human-readable name
|
||||
fingerprint: 'w.callback = function(v) {...}' # regex-ish
|
||||
semantic: "subscribe to widget value change" # what extensions are *trying* to do
|
||||
v2_replacement: "widget.on('change', fn)" # proposed
|
||||
decision_ref: D3.3 # which decision doc covers it
|
||||
test_target: WIDGET_VALUE_CHANGE_LISTENER # test framework symbol
|
||||
evidence:
|
||||
- repo: crom8505/ComfyUI-Dynamic-Sigmas
|
||||
file: web/js/graph_sigmas.js
|
||||
lines: [79, 80]
|
||||
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
|
||||
stars: 12 # github stars (cached, asof date)
|
||||
stars_asof: 2026-05-06
|
||||
variant: canonical # canonical | unsafe | with-bind | tempCallback-swap | per-instance | prototype
|
||||
breakage_class: silent # silent | loud | undefined-behavior | crash
|
||||
notes: "fourteen instances in same file"
|
||||
derived:
|
||||
occurrences: 7 # rolled up from evidence
|
||||
repos_touched: 5
|
||||
cumulative_stars: 245
|
||||
canonical_signatures: 1 # how many distinct shapes seen (P4 had 6 for onConnectionsChange!)
|
||||
breakage_classes: [silent, undefined-behavior]
|
||||
blast_radius: 3.2 # see formula
|
||||
```
|
||||
|
||||
## Blast-radius scoring formula
|
||||
|
||||
Goal: rank patterns by how disruptive their breakage would be in v2 rollout.
|
||||
|
||||
```
|
||||
blast_radius = 0.40 * log10(1 + cumulative_stars)
|
||||
+ 0.20 * log10(1 + occurrences)
|
||||
+ 0.15 * canonical_signatures # more shapes = more migration cases to support
|
||||
+ 0.15 * silent_breakage_weight # silent > loud > crash for danger
|
||||
+ 0.10 * lifecycle_coupling # 0/1/2; widgets that break on serialize timing get 2
|
||||
```
|
||||
|
||||
Where:
|
||||
- `silent_breakage_weight` = max over evidence: silent=1.0, undefined=0.6, loud=0.3, crash=0.2
|
||||
- `lifecycle_coupling` = 0 (none) | 1 (depends on init/teardown order) | 2 (depends on serialization-timing or DOM-mount-timing)
|
||||
|
||||
Rationale:
|
||||
- `log10` on stars + occurrences damps mega-popular packs from drowning out long-tail diversity
|
||||
- Silent breakage scores higher than loud — these are the ones that destroy trust
|
||||
- Lifecycle coupling captures widget-api-thoughts.md concerns (3D, webcam)
|
||||
- Canonical signatures captures "the API has no schema" risk (R4 P4 with 6 sigs)
|
||||
|
||||
A blast_radius ≥ 3.0 = MUST have a v1-compat shim or the migration story breaks.
|
||||
|
||||
## Star-fetching strategy
|
||||
|
||||
For each unique repo:
|
||||
```bash
|
||||
gh api "repos/<owner>/<name>" --jq '.stargazers_count'
|
||||
```
|
||||
|
||||
Cache in `research/touch-points/star-cache.yaml`:
|
||||
```yaml
|
||||
- repo: crom8505/ComfyUI-Dynamic-Sigmas
|
||||
stars: 12
|
||||
asof: 2026-05-06
|
||||
```
|
||||
|
||||
Refresh quarterly. If gh CLI errors (rate limit, repo gone), record `stars: null` and `error: <reason>`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Plan + schema (this doc)** ✅
|
||||
2. **Build initial database** — start with the 12 patterns from R4, structured properly
|
||||
3. **Sweep S1–S8 systematically** — batched code search, populate evidence
|
||||
4. **Star fetch pass** — `gh api` for every unique repo, populate cache
|
||||
5. **Compute derived fields** — script that rolls up evidence into derived metrics
|
||||
6. **Generate ranked report** — `database-by-blast-radius.md`
|
||||
7. **Map to test framework** — each pattern_id → test symbol
|
||||
|
||||
## Dispatch strategy for queries
|
||||
|
||||
- ~50 queries needed across S1–S8 (each surface gets 1-3 queries)
|
||||
- Run in parallel batches of 4-6 (MCP tolerates this if no DNS error)
|
||||
- Retry failed queries with 3-token reformulations (R4 workaround)
|
||||
- After each batch: append findings to `database.yaml`, never overwrite
|
||||
- After full sweep: run star-fetch script, run roll-up script
|
||||
|
||||
## Integration with the test framework
|
||||
|
||||
Each pattern in the database becomes a test triple:
|
||||
|
||||
1. **v1 contract test** (legacy): proves the v1 hook still works for shimmed extensions
|
||||
2. **v2 contract test** (new): proves the v2 replacement covers the same semantic
|
||||
3. **Migration test**: takes a real extension snippet from evidence, confirms it works in v2 (or fails with a documented compat error)
|
||||
|
||||
The test framework's "compatibility floor" is: every blast_radius ≥ 2.0 entry MUST pass all three tests before v2 ships.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Sandboxing model (Chrome-extension-style isolation): noted in CONTEXT.md, deferred
|
||||
- Performance benchmarks vs v1: separate workstream
|
||||
- Documentation generation from the database: separate workstream
|
||||
- npm package design for `@comfyui/extension-api`: separate workstream (per R4 P11 finding)
|
||||
1184
docs/architecture/extension-api-v2/touch-points-rollup.yaml
Normal file
724
docs/architecture/extension-api-v2/touch-points-star-cache.yaml
Normal file
@@ -0,0 +1,724 @@
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# GitHub star cache for repos referenced in database.yaml
|
||||
# Refresh: bash scripts/fetch-stars.sh
|
||||
# Asof dates allow drift detection
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
asof: 2026-05-08
|
||||
populated_via: scripts/fetch-stars.sh
|
||||
|
||||
repos:
|
||||
- repo: 40740/ComfyUI_LayerStyle_Bmss
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2024-10-16
|
||||
asof: 2026-05-08
|
||||
- repo: 834t/ComfyUI_834t_scene_composer
|
||||
stars: 5
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-03
|
||||
asof: 2026-05-08
|
||||
- repo: aicocoa981/WhatDreamsCost-ComfyUI-private
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-30
|
||||
asof: 2026-05-08
|
||||
- repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
|
||||
stars: 405
|
||||
archived: false
|
||||
forks: 25
|
||||
last_commit: 2025-10-27
|
||||
asof: 2026-05-08
|
||||
- repo: akawana/ComfyUI-Folded-Prompts
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-04-30
|
||||
asof: 2026-05-08
|
||||
- repo: AkihaTatsu/ComfyUI-Simple-Utility-Nodes
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-11
|
||||
asof: 2026-05-08
|
||||
- repo: alankent/ComfyUI-OA-360-Clip
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-11-16
|
||||
asof: 2026-05-08
|
||||
- repo: AlexZ1967/ComfyUI_ALEXZ_tools
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: ameliacode/comfyui-face3d
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-23
|
||||
asof: 2026-05-08
|
||||
- repo: andreszs/ComfyUI-Ultralytics-Studio
|
||||
stars: 3
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-13
|
||||
asof: 2026-05-08
|
||||
- repo: ArtHommage/HommageTools
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-05-20
|
||||
asof: 2026-05-08
|
||||
- repo: Azornes/Comfyui-LayerForge
|
||||
stars: 312
|
||||
archived: false
|
||||
forks: 16
|
||||
last_commit: 2026-05-01
|
||||
asof: 2026-05-08
|
||||
- repo: becky3/comfyui-workspace
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-13
|
||||
asof: 2026-05-08
|
||||
- repo: BennyKok/comfyui-deploy
|
||||
stars: 1508
|
||||
archived: false
|
||||
forks: 222
|
||||
last_commit: 2025-11-13
|
||||
asof: 2026-05-08
|
||||
- repo: brycecovert/ComfyUI-compass-images
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-23
|
||||
asof: 2026-05-08
|
||||
- repo: choovin/comfyui-api
|
||||
stars: 3
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-08
|
||||
asof: 2026-05-08
|
||||
- repo: chyer/Chye-ComfyUI-Toolset
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-03-10
|
||||
asof: 2026-05-08
|
||||
- repo: coeuskoalemoss/comfyUI-layerstyle-custom
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-06-23
|
||||
asof: 2026-05-08
|
||||
- repo: ComfyNodePRs/PR-comfyui-pkg39-ccab78b5
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2024-07-31
|
||||
asof: 2026-05-08
|
||||
- repo: Comfy-Org/ComfyUI_frontend
|
||||
stars: 1787
|
||||
archived: false
|
||||
forks: 563
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: Comfy-Org/ComfyUI-Manager
|
||||
stars: 14564
|
||||
archived: false
|
||||
forks: 2187
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: Comfy-Org/ComfyUI-test-framework
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-03-23
|
||||
asof: 2026-05-08
|
||||
- repo: ComfyUI-Kelin/ComfyUI_Image_Anything
|
||||
stars: 3
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-20
|
||||
asof: 2026-05-08
|
||||
- repo: Creepybits/ComfyUI-Creepy_nodes
|
||||
stars: 29
|
||||
archived: false
|
||||
forks: 5
|
||||
last_commit: 2026-04-14
|
||||
asof: 2026-05-08
|
||||
- repo: criskb/Comfypencil
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-13
|
||||
asof: 2026-05-08
|
||||
- repo: criskb/Fancy_Grid
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-13
|
||||
asof: 2026-05-08
|
||||
- repo: crom8505/ComfyUI-Dynamic-Sigmas
|
||||
stars: 8
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-03-30
|
||||
asof: 2026-05-08
|
||||
- repo: Damkohler/jlc-comfyui-nodes
|
||||
stars: 16
|
||||
archived: false
|
||||
forks: 4
|
||||
last_commit: 2026-04-17
|
||||
asof: 2026-05-08
|
||||
- repo: darth-veitcher/comfyui-ollama-model-manager
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-11-05
|
||||
asof: 2026-05-08
|
||||
- repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
|
||||
stars: 7
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-04-22
|
||||
asof: 2026-05-08
|
||||
- repo: diodiogod/TTS-Audio-Suite
|
||||
stars: 911
|
||||
archived: false
|
||||
forks: 101
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: dorpxam/ComfyUI-LTX2-Microscope
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-23
|
||||
asof: 2026-05-08
|
||||
- repo: DumiFlex/ComfyUI-Wildcard-Pipeline
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-04-08
|
||||
asof: 2026-05-08
|
||||
- repo: egormly/ComfyUI-EG_Tools
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-11-19
|
||||
asof: 2026-05-08
|
||||
- repo: EmanuelRiquelme/comfyui-art-venture
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2024-09-04
|
||||
asof: 2026-05-08
|
||||
- repo: EnragedAntelope/EA_LMStudio
|
||||
stars: 7
|
||||
archived: false
|
||||
forks: 4
|
||||
last_commit: 2026-04-22
|
||||
asof: 2026-05-08
|
||||
- repo: Firetheft/ComfyUI-Animate-Progress
|
||||
stars: 3
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-09-09
|
||||
asof: 2026-05-08
|
||||
- repo: FloyoAI/ComfyUI-SoundFlow
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-11-21
|
||||
asof: 2026-05-08
|
||||
- repo: flymyd/koishi-plugin-comfyui-client
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-08-21
|
||||
asof: 2026-05-08
|
||||
- repo: FunnyFinger/Dynamic_Sliders_stack
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2025-04-22
|
||||
asof: 2026-05-08
|
||||
- repo: gigici/ComfyUI_BlendPack
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: goodtab/ComfyUI-Custom-Scripts
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2024-09-02
|
||||
asof: 2026-05-08
|
||||
- repo: guido-gfv/gfv_pro_upgrade
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-20
|
||||
asof: 2026-05-08
|
||||
- repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2024-05-22
|
||||
asof: 2026-05-08
|
||||
- repo: hernantech/comfymcp
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-01-03
|
||||
asof: 2026-05-08
|
||||
- repo: hhayiyuan/ComfyUI-FFmpegURLMedia
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-01-02
|
||||
asof: 2026-05-08
|
||||
- repo: huafitwjb/ComfyUI-GO-Mobile-app
|
||||
stars: 6
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-03-04
|
||||
asof: 2026-05-08
|
||||
- repo: ialhabbal/compare
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-03-31
|
||||
asof: 2026-05-08
|
||||
- repo: IAMCCS/IAMCCS-nodes
|
||||
stars: 92
|
||||
archived: false
|
||||
forks: 6
|
||||
last_commit: 2026-05-04
|
||||
asof: 2026-05-08
|
||||
- repo: IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-03-15
|
||||
asof: 2026-05-08
|
||||
- repo: JichaoLiang/Immortal_comfyui_public
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-12-05
|
||||
asof: 2026-05-08
|
||||
- repo: jiekouai/ComfyUI-JieKou-API
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-25
|
||||
asof: 2026-05-08
|
||||
- repo: jonstreeter/comfyui-compressed-metadata
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-10-12
|
||||
asof: 2026-05-08
|
||||
- repo: ketle-man/ComfyUI-Workflow-Studio
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-28
|
||||
asof: 2026-05-08
|
||||
- repo: kijai/ComfyUI-KJNodes
|
||||
stars: 2569
|
||||
archived: false
|
||||
forks: 292
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: koshimazaki/ComfyUI-Koshi-Nodes
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-12
|
||||
asof: 2026-05-08
|
||||
- repo: krismasdev/ComfyUI-Flux-Continuum
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-08-04
|
||||
asof: 2026-05-08
|
||||
- repo: KumihoIO/kumiho-plugins
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-28
|
||||
asof: 2026-05-08
|
||||
- repo: kyuz0/amd-strix-halo-comfyui-toolboxes
|
||||
stars: 109
|
||||
archived: false
|
||||
forks: 14
|
||||
last_commit: 2026-02-13
|
||||
asof: 2026-05-08
|
||||
- repo: LaoMaoBoss/ComfyUI-WBLESS
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-07
|
||||
asof: 2026-05-08
|
||||
- repo: Lightricks/ComfyUI-LTXVideo
|
||||
stars: 3587
|
||||
archived: false
|
||||
forks: 390
|
||||
last_commit: 2026-04-26
|
||||
asof: 2026-05-08
|
||||
- repo: linjm8780860/ljm_comfyui
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-29
|
||||
asof: 2026-05-08
|
||||
- repo: lordwedggie/xcpNodes
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: lucafoscili/lf-nodes
|
||||
stars: 34
|
||||
archived: false
|
||||
forks: 3
|
||||
last_commit: 2025-12-23
|
||||
asof: 2026-05-08
|
||||
- repo: m3rr/h4_Live
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-05-07
|
||||
asof: 2026-05-08
|
||||
- repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
|
||||
stars: 97
|
||||
archived: false
|
||||
forks: 6
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: max-dingsda/ComfyUI-AllinOne-LazyNode
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-03-30
|
||||
asof: 2026-05-08
|
||||
- repo: maxi45274/ComfyUI_LinkFX
|
||||
stars: 3
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: melMass/comfy_mtb
|
||||
stars: 702
|
||||
archived: false
|
||||
forks: 82
|
||||
last_commit: 2026-03-19
|
||||
asof: 2026-05-08
|
||||
- repo: MockbaTheBorg/ComfyUI-Mockba
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-13
|
||||
asof: 2026-05-08
|
||||
- repo: mudknight/comfyui-mudknight-utils
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: MuhammadMuradKhan/efficiency-nodes-comfyui
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2024-02-11
|
||||
asof: 2026-05-08
|
||||
- repo: niknah/presentation-ComfyUI
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-04-21
|
||||
asof: 2026-05-08
|
||||
- repo: nodelee733/ComfyUI-mxToolkit
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-27
|
||||
asof: 2026-05-08
|
||||
- repo: nodetool-ai/nodetool
|
||||
stars: 332
|
||||
archived: false
|
||||
forks: 40
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: nvmax/aspect-ratio-resizer
|
||||
stars: 5
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-23
|
||||
asof: 2026-05-08
|
||||
- repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
|
||||
stars: 45
|
||||
archived: false
|
||||
forks: 4
|
||||
last_commit: 2025-08-09
|
||||
asof: 2026-05-08
|
||||
- repo: PGCRT/CRT-Nodes
|
||||
stars: 108
|
||||
archived: false
|
||||
forks: 14
|
||||
last_commit: 2026-05-03
|
||||
asof: 2026-05-08
|
||||
- repo: philippjbauer/devint25-comfyui-api-demo
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-10-09
|
||||
asof: 2026-05-08
|
||||
- repo: pictorialink/ComfyUI-Easy-Use
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-07-15
|
||||
asof: 2026-05-08
|
||||
- repo: PioneerMNDR/ComfyUI-Polza
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-23
|
||||
asof: 2026-05-08
|
||||
- repo: pixaroma/ComfyUI-Pixaroma
|
||||
stars: 146
|
||||
archived: false
|
||||
forks: 9
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: PROJECTMAD/PROJECT-MAD-NODES
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-04-01
|
||||
asof: 2026-05-08
|
||||
- repo: Raykosan/ComfyUI_RaykoStudio
|
||||
stars: 45
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-05-05
|
||||
asof: 2026-05-08
|
||||
- repo: rgthree/rgthree-comfy
|
||||
stars: 3054
|
||||
archived: false
|
||||
forks: 226
|
||||
last_commit: 2026-04-07
|
||||
asof: 2026-05-08
|
||||
- repo: robertvoy/ComfyUI-Distributed
|
||||
stars: 544
|
||||
archived: false
|
||||
forks: 57
|
||||
last_commit: 2026-04-26
|
||||
asof: 2026-05-08
|
||||
- repo: rohapa/comfyui-replay
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-27
|
||||
asof: 2026-05-08
|
||||
- repo: r-vage/ComfyUI_Eclipse
|
||||
stars: 19
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: ryanontheinside/ComfyUI_RyanOnTheInside
|
||||
stars: 801
|
||||
archived: false
|
||||
forks: 50
|
||||
last_commit: 2026-03-20
|
||||
asof: 2026-05-08
|
||||
- repo: sammykumar/ComfyUI-SwissArmyKnife
|
||||
stars: 5
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-01-14
|
||||
asof: 2026-05-08
|
||||
- repo: SaturMars/ComfyUI-NVVFR
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-08-05
|
||||
asof: 2026-05-08
|
||||
- repo: ShakerSmith/ShakerNodesSuite
|
||||
stars: 8
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-02-18
|
||||
asof: 2026-05-08
|
||||
- repo: shrimbly/willie-comfy-frontend
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-28
|
||||
asof: 2026-05-08
|
||||
- repo: SKBv0/ComfyUI_SKBundle
|
||||
stars: 116
|
||||
archived: false
|
||||
forks: 7
|
||||
last_commit: 2026-04-23
|
||||
asof: 2026-05-08
|
||||
- repo: SKBv0/ComfyUI_SpideyReroute
|
||||
stars: 13
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2025-12-19
|
||||
asof: 2026-05-08
|
||||
- repo: sofakid/dandy
|
||||
stars: 54
|
||||
archived: false
|
||||
forks: 4
|
||||
last_commit: 2025-12-15
|
||||
asof: 2026-05-08
|
||||
- repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-03-30
|
||||
asof: 2026-05-08
|
||||
- repo: SparknightLLC/ComfyUI-EnumCombo
|
||||
stars: 2
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: StableLlama/ComfyUI-basic_data_handling
|
||||
stars: 43
|
||||
archived: false
|
||||
forks: 7
|
||||
last_commit: 2026-05-07
|
||||
asof: 2026-05-08
|
||||
- repo: stavzszn/comfyui-teskors-utils
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-03-29
|
||||
asof: 2026-05-08
|
||||
- repo: Stibo/comfyui-nifty-nodes
|
||||
stars: 3
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-03-21
|
||||
asof: 2026-05-08
|
||||
- repo: Sunwood-ai-labs/ComfyUI-LTXLongAudio
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-01
|
||||
asof: 2026-05-08
|
||||
- repo: sypex6/ComfyUI_InstaRAW_Nodes
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-07
|
||||
asof: 2026-05-08
|
||||
- repo: tavyra/ComfyUI_Curves
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-05-08
|
||||
asof: 2026-05-08
|
||||
- repo: tetsuoo-online/Comfyui-TOO-Pack
|
||||
stars: 4
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-04-02
|
||||
asof: 2026-05-08
|
||||
- repo: touge/ComfyUI-NCE_Utils
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-01-25
|
||||
asof: 2026-05-08
|
||||
- repo: treforyan-hue/comfyui-deploy
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-05-05
|
||||
asof: 2026-05-08
|
||||
- repo: Valiant-Cat/ComfyUI-WanMove-Trajectory
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-12-12
|
||||
asof: 2026-05-08
|
||||
- repo: viswamohankomati/ComfyUI-Copilot
|
||||
stars: 0
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-09-17
|
||||
asof: 2026-05-08
|
||||
- repo: vjumpkung/comfyui-infinitetalk-native-sampler
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-03-31
|
||||
asof: 2026-05-08
|
||||
- repo: Winnougan/WINT8-ComfyUI
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 2
|
||||
last_commit: 2026-04-17
|
||||
asof: 2026-05-08
|
||||
- repo: xeinherjer-dev/ComfyUI-XENodes
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2026-05-07
|
||||
asof: 2026-05-08
|
||||
- repo: yardimli/SafetensorViewer
|
||||
stars: 7
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2025-02-19
|
||||
asof: 2026-05-08
|
||||
- repo: yolain/ComfyUI-Easy-Use
|
||||
stars: 2504
|
||||
archived: false
|
||||
forks: 195
|
||||
last_commit: 2026-04-29
|
||||
asof: 2026-05-08
|
||||
- repo: yolain/ComfyUI-Easy-Use-Frontend
|
||||
stars: 27
|
||||
archived: false
|
||||
forks: 9
|
||||
last_commit: 2026-04-01
|
||||
asof: 2026-05-08
|
||||
- repo: yorkane/ComfyUI-KYNode
|
||||
stars: 10
|
||||
archived: false
|
||||
forks: 4
|
||||
last_commit: 2026-02-04
|
||||
asof: 2026-05-08
|
||||
- repo: zhupeter010903/ComfyUI-XYZ-prompt-library
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-26
|
||||
asof: 2026-05-08
|
||||
- repo: zzggi2024/shaobkj
|
||||
stars: 1
|
||||
archived: false
|
||||
forks: 0
|
||||
last_commit: 2026-04-27
|
||||
asof: 2026-05-08
|
||||
- repo: zzw5516/ComfyUI-zw-tools
|
||||
stars: 6
|
||||
archived: false
|
||||
forks: 1
|
||||
last_commit: 2025-12-03
|
||||
asof: 2026-05-08
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.17",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -47,6 +47,9 @@
|
||||
"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:extension-api": "vitest run --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
3
packages/extension-api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
docs-build/
|
||||
build/
|
||||
node_modules/
|
||||
50
packages/extension-api/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @comfyorg/extension-api
|
||||
|
||||
> **Status**: scaffolded. Package implementation pending PKG3 — see
|
||||
> `../../../plans/P2-extension-api-package.md` and
|
||||
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
|
||||
|
||||
The official TypeScript declaration package for ComfyUI extensions. This
|
||||
package replaces the practice of vendoring `comfy.d.ts` files in custom
|
||||
node repos.
|
||||
|
||||
## Install (post-publish)
|
||||
|
||||
```bash
|
||||
pnpm add -D @comfyorg/extension-api
|
||||
```
|
||||
|
||||
```ts
|
||||
import { defineExtension } from '@comfyorg/extension-api'
|
||||
|
||||
export default defineExtension({
|
||||
name: 'MyExtension',
|
||||
setup(ctx) {
|
||||
ctx.onNodeMounted((node) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
This package is built from the source-of-truth folder
|
||||
`../../src/extension-api/`. Do not edit the package's `build/` output
|
||||
directly.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
|
||||
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
|
||||
surface has stabilized.
|
||||
- Breaking changes follow semver strictly from `1.0.0` onward.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
|
||||
- `plans/P2-extension-api-package.md` — package structure plan
|
||||
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
|
||||
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
|
||||
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
|
||||
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration
|
||||
28
packages/extension-api/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@comfyorg/extension-api",
|
||||
"version": "0.1.0",
|
||||
"description": "Official TypeScript extension API for ComfyUI custom nodes",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./build/index.js"
|
||||
},
|
||||
"types": "./build/index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --emitDeclarationOnly --outDir build",
|
||||
"docs:build": "tsx scripts/build-docs.ts",
|
||||
"docs:watch": "tsx scripts/build-docs.ts --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"typedoc": "0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.6.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:api"
|
||||
]
|
||||
}
|
||||
}
|
||||
470
packages/extension-api/scripts/build-docs.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
|
||||
*
|
||||
* Steps:
|
||||
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
|
||||
* 2. Post-process each markdown file:
|
||||
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
|
||||
* - Convert ``` fences without lang tag → ```ts
|
||||
* - Replace raw [TypeName] cross-refs with MDX relative links
|
||||
* - Wrap @example blocks in proper code fences
|
||||
* 3. Write final .mdx files to docs-build/mintlify/
|
||||
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
|
||||
*
|
||||
* Run: pnpm --filter @comfyorg/extension-api docs:build
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgRoot = path.resolve(__dirname, '..')
|
||||
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
|
||||
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
|
||||
const watchMode = process.argv.includes('--watch')
|
||||
|
||||
// ── Page metadata ────────────────────────────────────────────────────────────
|
||||
// Controls frontmatter for each generated page. Key = TypeDoc output filename
|
||||
// stem (lowercased). Unrecognised files get generic metadata.
|
||||
|
||||
interface PageMeta {
|
||||
title: string
|
||||
sidebarTitle?: string
|
||||
description: string
|
||||
icon?: string
|
||||
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
|
||||
order: number
|
||||
}
|
||||
|
||||
const PAGE_META: Record<string, PageMeta> = {
|
||||
// Top-level overview
|
||||
index: {
|
||||
title: 'Extension API Overview',
|
||||
description: 'TypeScript API reference for ComfyUI custom node extensions.',
|
||||
icon: 'puzzle-piece',
|
||||
group: 'root',
|
||||
order: 0
|
||||
},
|
||||
// Lifecycle / registration
|
||||
defineextension: {
|
||||
title: 'defineExtension',
|
||||
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 1
|
||||
},
|
||||
definenodeextension: {
|
||||
title: 'defineNodeExtension',
|
||||
description: 'Register a node-scoped extension reacting to node lifecycle events.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 2
|
||||
},
|
||||
definewidgetextension: {
|
||||
title: 'defineWidgetExtension',
|
||||
description: 'Register a custom widget type with its own DOM rendering.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 3
|
||||
},
|
||||
extensionoptions: {
|
||||
title: 'ExtensionOptions',
|
||||
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
|
||||
group: 'core',
|
||||
order: 4
|
||||
},
|
||||
nodeextensionoptions: {
|
||||
title: 'NodeExtensionOptions',
|
||||
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
|
||||
group: 'core',
|
||||
order: 5
|
||||
},
|
||||
widgetextensionoptions: {
|
||||
title: 'WidgetExtensionOptions',
|
||||
description: 'Options object for defineWidgetExtension — custom widget rendering.',
|
||||
group: 'core',
|
||||
order: 6
|
||||
},
|
||||
onnoderemoved: {
|
||||
title: 'onNodeRemoved',
|
||||
sidebarTitle: 'onNodeRemoved',
|
||||
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
|
||||
group: 'core',
|
||||
order: 7
|
||||
},
|
||||
onnodemounted: {
|
||||
title: 'onNodeMounted',
|
||||
sidebarTitle: 'onNodeMounted',
|
||||
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
|
||||
group: 'core',
|
||||
order: 8
|
||||
},
|
||||
// Handles
|
||||
nodehandle: {
|
||||
title: 'NodeHandle',
|
||||
description: 'Controlled access to node state, mutations, slots, and events.',
|
||||
icon: 'circle-nodes',
|
||||
group: 'handles',
|
||||
order: 10
|
||||
},
|
||||
widgethandle: {
|
||||
title: 'WidgetHandle',
|
||||
description: 'Controlled access to widget state, mutations, and events.',
|
||||
icon: 'sliders',
|
||||
group: 'handles',
|
||||
order: 11
|
||||
},
|
||||
slotinfo: {
|
||||
title: 'SlotInfo',
|
||||
description: 'Read-only snapshot of a node slot (input or output).',
|
||||
group: 'handles',
|
||||
order: 12
|
||||
},
|
||||
// Events
|
||||
nodeexecutedevent: {
|
||||
title: 'NodeExecutedEvent',
|
||||
description: 'Payload fired when a node finishes execution.',
|
||||
group: 'events',
|
||||
order: 20
|
||||
},
|
||||
nodeconnectedevent: {
|
||||
title: 'NodeConnectedEvent',
|
||||
description: 'Payload fired when a slot connection is made.',
|
||||
group: 'events',
|
||||
order: 21
|
||||
},
|
||||
nodedisconnectedevent: {
|
||||
title: 'NodeDisconnectedEvent',
|
||||
description: 'Payload fired when a slot connection is removed.',
|
||||
group: 'events',
|
||||
order: 22
|
||||
},
|
||||
nodepositionchangedevent: {
|
||||
title: 'NodePositionChangedEvent',
|
||||
description: 'Payload fired when a node is moved on the canvas.',
|
||||
group: 'events',
|
||||
order: 23
|
||||
},
|
||||
nodesizechangedevent: {
|
||||
title: 'NodeSizeChangedEvent',
|
||||
description: 'Payload fired when a node is resized.',
|
||||
group: 'events',
|
||||
order: 24
|
||||
},
|
||||
nodemodechangedevent: {
|
||||
title: 'NodeModeChangedEvent',
|
||||
description: 'Payload fired when a node execution mode changes.',
|
||||
group: 'events',
|
||||
order: 25
|
||||
},
|
||||
nodebeforeserializeevent: {
|
||||
title: 'NodeBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip node data.',
|
||||
group: 'events',
|
||||
order: 26
|
||||
},
|
||||
widgetvaluechangeevent: {
|
||||
title: 'WidgetValueChangeEvent',
|
||||
description: 'Payload fired when a widget value changes.',
|
||||
group: 'events',
|
||||
order: 27
|
||||
},
|
||||
widgetbeforeserializeevent: {
|
||||
title: 'WidgetBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip widget value.',
|
||||
group: 'events',
|
||||
order: 28
|
||||
},
|
||||
widgetbeforequeueevent: {
|
||||
title: 'WidgetBeforeQueueEvent',
|
||||
description: 'Pre-queue validation payload — call reject() to cancel queue.',
|
||||
group: 'events',
|
||||
order: 29
|
||||
},
|
||||
// Shell UI
|
||||
sidebartabextension: {
|
||||
title: 'SidebarTabExtension',
|
||||
description: 'Register a custom sidebar tab.',
|
||||
group: 'shell',
|
||||
order: 40
|
||||
},
|
||||
bottompanelextension: {
|
||||
title: 'BottomPanelExtension',
|
||||
description: 'Register a custom bottom panel tab.',
|
||||
group: 'shell',
|
||||
order: 41
|
||||
},
|
||||
toastmanager: {
|
||||
title: 'ToastManager',
|
||||
description: 'Show toast notifications to the user.',
|
||||
group: 'shell',
|
||||
order: 42
|
||||
},
|
||||
commandmanager: {
|
||||
title: 'CommandManager',
|
||||
description: 'Register keyboard shortcuts and command palette entries.',
|
||||
group: 'shell',
|
||||
order: 43
|
||||
},
|
||||
extensionmanager: {
|
||||
title: 'ExtensionManager',
|
||||
description: 'Access shell UI registration APIs.',
|
||||
group: 'shell',
|
||||
order: 44
|
||||
},
|
||||
// Identity
|
||||
nodelocatorid: {
|
||||
title: 'NodeLocatorId',
|
||||
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
|
||||
group: 'identity',
|
||||
order: 50
|
||||
},
|
||||
nodeexecutionid: {
|
||||
title: 'NodeExecutionId',
|
||||
description: 'Branded string ID for a specific node execution run.',
|
||||
group: 'identity',
|
||||
order: 51
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<PageMeta['group'], string> = {
|
||||
root: 'Extensions API',
|
||||
core: 'Registration',
|
||||
handles: 'Handles',
|
||||
events: 'Events',
|
||||
shell: 'Shell UI',
|
||||
identity: 'Identity'
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function slug(stem: string): string {
|
||||
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function metaFor(stem: string): PageMeta {
|
||||
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
|
||||
return (
|
||||
PAGE_META[key] ?? {
|
||||
title: stem,
|
||||
description: `API reference for ${stem}.`,
|
||||
group: 'core',
|
||||
order: 99
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
|
||||
function toMintlifyMdx(raw: string, stem: string): string {
|
||||
const meta = metaFor(stem)
|
||||
|
||||
// Build frontmatter
|
||||
const fm: string[] = [
|
||||
`---`,
|
||||
`title: "${meta.title}"`,
|
||||
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
|
||||
`description: "${meta.description}"`,
|
||||
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
|
||||
`---`
|
||||
]
|
||||
|
||||
let body = raw
|
||||
|
||||
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
|
||||
|
||||
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
|
||||
body = body.replace(/^# .+\n+/, '')
|
||||
|
||||
// Ensure opening code fences that have no lang tag get `ts`
|
||||
// Only match a ``` that is immediately followed by a newline (opening fence),
|
||||
// not a closing fence (which also has just ``` + newline but we can detect
|
||||
// by context: opening fences follow non-fence lines; closing fences follow content).
|
||||
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
|
||||
// We track state via a flag pass instead of a single regex.
|
||||
let inBlock = false
|
||||
body = body
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (inBlock) {
|
||||
if (line.trim() === '```') { inBlock = false; return line }
|
||||
return line
|
||||
}
|
||||
if (line.startsWith('```')) {
|
||||
if (line.trim() === '```') {
|
||||
// bare opening fence → add ts
|
||||
inBlock = true
|
||||
return '```ts'
|
||||
}
|
||||
// has a lang tag already
|
||||
inBlock = true
|
||||
return line
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// TypeDoc emits `typescript` lang tag; normalize to `ts`
|
||||
body = body.replace(/^```typescript\b/gm, '```ts')
|
||||
|
||||
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
|
||||
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
|
||||
(_match, label, _category, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
// Same-dir links
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
|
||||
(_match, label, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
|
||||
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
|
||||
// code examples to be directly under prose without a sub-heading.
|
||||
// Flatten "## Example\n\n```ts" → "```ts"
|
||||
body = body.replace(/^## Example\s*\n+/gm, '')
|
||||
|
||||
// Stability tags: render as a <Tip> callout
|
||||
body = body.replace(
|
||||
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
|
||||
(_match, level) => {
|
||||
const label =
|
||||
level === 'stable'
|
||||
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
|
||||
: level === 'experimental'
|
||||
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
|
||||
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
|
||||
return label
|
||||
}
|
||||
)
|
||||
|
||||
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
|
||||
body = body.replace(
|
||||
/^Stability: (stable|experimental|deprecated)\s*$/gm,
|
||||
(_match, level) => {
|
||||
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
|
||||
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
|
||||
return '<Warning>**Stability:** Deprecated</Warning>'
|
||||
}
|
||||
)
|
||||
|
||||
return [...fm, '', body.trim(), ''].join('\n')
|
||||
}
|
||||
|
||||
// ── Nav snippet builder ───────────────────────────────────────────────────────
|
||||
|
||||
interface NavPage {
|
||||
group?: string
|
||||
pages: (string | NavPage)[]
|
||||
}
|
||||
|
||||
function buildNavSnippet(stems: string[]): NavPage {
|
||||
const byGroup: Record<string, string[]> = {}
|
||||
|
||||
for (const stem of stems) {
|
||||
const meta = metaFor(stem)
|
||||
const group = meta.group
|
||||
if (!byGroup[group]) byGroup[group] = []
|
||||
byGroup[group].push(`extensions/api/${slug(stem)}`)
|
||||
}
|
||||
|
||||
// Sort each group by order
|
||||
const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order)
|
||||
const sortedByGroup: Record<string, string[]> = {}
|
||||
for (const stem of sortedStems) {
|
||||
const group = metaFor(stem).group
|
||||
if (!sortedByGroup[group]) sortedByGroup[group] = []
|
||||
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
|
||||
}
|
||||
|
||||
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
|
||||
|
||||
const pages: (string | NavPage)[] = []
|
||||
|
||||
// Overview at top level
|
||||
if (sortedByGroup['root']) {
|
||||
for (const p of sortedByGroup['root']) pages.push(p)
|
||||
}
|
||||
|
||||
for (const grp of groupOrder) {
|
||||
if (grp === 'root') continue
|
||||
const grpPages = sortedByGroup[grp]
|
||||
if (!grpPages?.length) continue
|
||||
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
|
||||
}
|
||||
|
||||
return { group: 'Extensions API', pages }
|
||||
}
|
||||
|
||||
// ── Main pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
function runTypedoc(): void {
|
||||
console.log('▶ Running TypeDoc...')
|
||||
execSync(
|
||||
`npx typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
|
||||
{ cwd: pkgRoot, stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
function processFiles(): void {
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
|
||||
}
|
||||
|
||||
fs.mkdirSync(mintlifyDir, { recursive: true })
|
||||
|
||||
const mdFiles = fs.readdirSync(rawDir, { recursive: true })
|
||||
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
|
||||
|
||||
const stems: string[] = []
|
||||
|
||||
for (const relPath of mdFiles) {
|
||||
const src = path.join(rawDir, relPath)
|
||||
const stem = path.basename(relPath, '.md')
|
||||
const raw = fs.readFileSync(src, 'utf8')
|
||||
const mdx = toMintlifyMdx(raw, stem)
|
||||
|
||||
const destName = slug(stem) + '.mdx'
|
||||
const dest = path.join(mintlifyDir, destName)
|
||||
fs.writeFileSync(dest, mdx)
|
||||
console.log(` ✔ ${relPath} → mintlify/${destName}`)
|
||||
stems.push(stem)
|
||||
}
|
||||
|
||||
// Write nav snippet
|
||||
const nav = buildNavSnippet(stems)
|
||||
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
|
||||
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
|
||||
console.log(` ✔ nav-snippet.json`)
|
||||
|
||||
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
|
||||
console.log(` ${stems.length} pages + nav-snippet.json`)
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
runTypedoc()
|
||||
processFiles()
|
||||
}
|
||||
|
||||
if (watchMode) {
|
||||
// Simple watch: re-run on change to source files
|
||||
console.log('👁 Watch mode — watching src/extension-api/**')
|
||||
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
run()
|
||||
|
||||
fs.watch(srcDir, { recursive: true }, () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => {
|
||||
console.log('\n🔄 Source changed — rebuilding...')
|
||||
try { run() } catch (e) { console.error(e) }
|
||||
}, 500)
|
||||
})
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
21
packages/extension-api/tsconfig.docs.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../../src/extension-api/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../../src/**/*.test.ts",
|
||||
"../../src/**/*.spec.ts",
|
||||
"../../src/**/*.vue"
|
||||
]
|
||||
}
|
||||
37
packages/extension-api/typedoc.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"entryPoints": ["../../src/extension-api/index.ts"],
|
||||
"tsconfig": "./tsconfig.docs.json",
|
||||
"out": "./docs-build/raw",
|
||||
"plugin": ["typedoc-plugin-markdown"],
|
||||
"excludeInternal": true,
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": true,
|
||||
"readme": "none",
|
||||
"skipErrorChecking": true,
|
||||
"githubPages": false,
|
||||
"blockTags": ["@stability", "@packageDocumentation", "@example", "@typeParam", "@returns", "@deprecated", "@remarks"],
|
||||
"hideGenerator": true,
|
||||
"useCodeBlocks": true,
|
||||
"flattenOutputFiles": false,
|
||||
"entryFileName": "index",
|
||||
"fileExtension": ".md",
|
||||
"outputFileStrategy": "members",
|
||||
"hidePageHeader": false,
|
||||
"hideBreadcrumbs": false,
|
||||
"useHTMLAnchors": false,
|
||||
"sanitizeComments": true,
|
||||
"expandObjects": false,
|
||||
"parametersFormat": "table",
|
||||
"propertiesFormat": "table",
|
||||
"typeDeclarationFormat": "table",
|
||||
"indexFormat": "table",
|
||||
"tableColumnSettings": {
|
||||
"hideDefaults": false,
|
||||
"hideInherited": false,
|
||||
"hideModifiers": false,
|
||||
"hideOverrides": false,
|
||||
"hideSources": true,
|
||||
"hideValues": false,
|
||||
"leftAlignHeaders": false
|
||||
}
|
||||
}
|
||||
@@ -12575,12 +12575,16 @@ export interface components {
|
||||
Rodin3DGenerateRequest: {
|
||||
/** @description The reference images to generate 3D Assets. */
|
||||
images: string;
|
||||
/** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
|
||||
prompt?: string;
|
||||
/** @description Seed. */
|
||||
seed?: number;
|
||||
tier?: components["schemas"]["RodinTierType"];
|
||||
material?: components["schemas"]["RodinMaterialType"];
|
||||
quality?: components["schemas"]["RodinQualityType"];
|
||||
mesh_mode?: components["schemas"]["RodinMeshModeType"];
|
||||
/** @description Optional list of upstream addon flags (e.g. "HighPack"). */
|
||||
addons?: string[];
|
||||
};
|
||||
/**
|
||||
* @description Rodin Tier para options
|
||||
|
||||
28
pnpm-lock.yaml
generated
@@ -160,8 +160,8 @@ catalogs:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
specifier: ^0.170.0
|
||||
version: 0.170.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
@@ -339,6 +339,9 @@ catalogs:
|
||||
tailwindcss-primeui:
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
three:
|
||||
specifier: ^0.170.0
|
||||
version: 0.170.0
|
||||
tsx:
|
||||
specifier: ^4.15.6
|
||||
version: 4.19.4
|
||||
@@ -698,7 +701,7 @@ importers:
|
||||
version: 7.7.0
|
||||
'@types/three':
|
||||
specifier: 'catalog:'
|
||||
version: 0.169.0
|
||||
version: 0.170.0
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -964,6 +967,9 @@ importers:
|
||||
posthog-js:
|
||||
specifier: 'catalog:'
|
||||
version: 1.358.1
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.170.0
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4508,8 +4514,8 @@ packages:
|
||||
'@types/stats.js@0.17.3':
|
||||
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
|
||||
|
||||
'@types/three@0.169.0':
|
||||
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
|
||||
'@types/three@0.170.0':
|
||||
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
@@ -9883,8 +9889,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.7:
|
||||
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||
vue-component-type-helpers@3.2.8:
|
||||
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -13405,7 +13411,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.7
|
||||
vue-component-type-helpers: 3.2.8
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13834,7 +13840,7 @@ snapshots:
|
||||
|
||||
'@types/stats.js@0.17.3': {}
|
||||
|
||||
'@types/three@0.169.0':
|
||||
'@types/three@0.170.0':
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
'@types/stats.js': 0.17.3
|
||||
@@ -14189,7 +14195,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20530,7 +20536,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.7: {}
|
||||
vue-component-type-helpers@3.2.8: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -54,7 +54,7 @@ catalog:
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@types/three': ^0.170.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
@@ -113,6 +113,7 @@ catalog:
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
@@ -115,6 +116,15 @@ def generate_av_fixture(
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_png():
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('workflow', WORKFLOW_JSON)
|
||||
info.add_text('prompt', PROMPT_JSON)
|
||||
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_metadata.png')
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
@@ -167,6 +177,7 @@ def generate_webm():
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
@@ -48,6 +51,7 @@
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -62,6 +66,7 @@
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -76,6 +81,7 @@
|
||||
v-model="selectedRunsOnObjects"
|
||||
:label="runsOnFilterLabel"
|
||||
:options="runsOnOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -92,6 +98,7 @@
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const selectContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
192
src/components/dialog/GlobalDialog.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const Body = defineComponent({
|
||||
name: 'Body',
|
||||
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return render(GlobalDialog, {
|
||||
global: { plugins: [PrimeVue, i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GlobalDialog renderer branching', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the Reka branch when renderer is reka', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-opt-in',
|
||||
title: 'Reka dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the renderer flag on the dialog stack item', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-flag-check',
|
||||
title: 'Reka',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
|
||||
expect(item?.dialogComponentProps.renderer).toBe('reka')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('omits the close button when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-not-closable',
|
||||
title: 'No close',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the close button by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-closable',
|
||||
title: 'Closable',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the title when headless is true', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-headless',
|
||||
title: 'Hidden title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', headless: true }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByText('Hidden title')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the title when headless is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-titled',
|
||||
title: 'Visible title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByText('Visible title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the dialog on Escape by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-default',
|
||||
title: 'Esc closes',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not close on Escape when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-blocked',
|
||||
title: 'Esc blocked',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,106 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="item in dialogStore.dialogStack" :key="item.key">
|
||||
<Dialog
|
||||
v-if="isRekaItem(item)"
|
||||
:open="item.visible"
|
||||
:modal="item.dialogComponentProps.modal ?? true"
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<DialogHeader v-if="!item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
<PrimeDialog
|
||||
v-else
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</Dialog>
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</PrimeDialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -53,6 +110,14 @@ const teamWorkspacesEnabled = computed(
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
return item.dialogComponentProps.renderer === 'reka'
|
||||
}
|
||||
|
||||
function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -42,4 +43,43 @@ describe('ConfirmationDialogContent', () => {
|
||||
renderComponent({ message: longFilename })
|
||||
expect(screen.getByText(longFilename)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the Cancel button when type is dirtyClose', () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('g.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
|
||||
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
|
||||
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
|
||||
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderComponent({
|
||||
type: 'dirtyClose',
|
||||
denyLabel: 'Close anyway',
|
||||
onConfirm
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderComponent({ type: 'dirtyClose', onConfirm })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('falls back to "no" label when denyLabel is not provided', () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(screen.getByText('g.no')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="type !== 'info'"
|
||||
v-if="type !== 'info' && type !== 'dirtyClose'"
|
||||
variant="secondary"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
@@ -86,9 +86,9 @@
|
||||
<template v-else-if="type === 'dirtyClose'">
|
||||
<Button variant="secondary" @click="onDeny">
|
||||
<i class="pi pi-times" />
|
||||
{{ $t('g.no') }}
|
||||
{{ denyLabel ?? $t('g.no') }}
|
||||
</Button>
|
||||
<Button @click="onConfirm">
|
||||
<Button autofocus @click="onConfirm">
|
||||
<i class="pi pi-save" />
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
@@ -131,6 +131,7 @@ const props = defineProps<{
|
||||
onConfirm: (value?: boolean) => void
|
||||
itemList?: string[]
|
||||
hint?: string
|
||||
denyLabel?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
@@ -15,10 +18,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
:content-style="keybindingOverlayContentStyle"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
:style="keybindingOverlayContentStyle"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
@@ -193,11 +198,12 @@
|
||||
</template>
|
||||
</Column>
|
||||
<template #expansion="slotProps">
|
||||
<div class="pl-4">
|
||||
<div class="pl-4" data-testid="keybinding-expansion-content">
|
||||
<div
|
||||
v-for="(binding, idx) in (slotProps.data as ICommandData)
|
||||
.keybindings"
|
||||
:key="binding.combo.serialize()"
|
||||
data-testid="keybinding-expansion-binding"
|
||||
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -237,6 +243,7 @@
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
@@ -313,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
@@ -336,6 +344,8 @@ const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
const presetNames = ref<string[]>([])
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
|
||||
<SelectContent
|
||||
:style="contentStyle"
|
||||
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
|
||||
>
|
||||
<div class="max-w-60">
|
||||
<SelectItem
|
||||
value="default"
|
||||
@@ -46,6 +49,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
|
||||
const { presetNames } = defineProps<{
|
||||
const { presetNames, contentStyle } = defineProps<{
|
||||
presetNames: string[]
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
164
src/components/helpcenter/HelpCenterMenuContent.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const commandStoreExecute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: { discord: '', github: '' },
|
||||
buildDocsUrl: () => 'https://docs.comfy.org'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackHelpCenterOpened: vi.fn(),
|
||||
trackHelpCenterClosed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
releases: [],
|
||||
recentReleases: [],
|
||||
isLoading: false,
|
||||
fetchReleases: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: commandStoreExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => null
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({ isNewManagerUI: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PuzzleIconStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(HelpCenterMenuContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('HelpCenterMenuContent feedback item', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
commandStoreExecute.mockReset()
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
trackResourceClick('help_feedback', isCloud || isNightly)
|
||||
if (isCloud || isNightly) {
|
||||
window.open(
|
||||
'https://form.typeform.com/to/q7azbWPi',
|
||||
buildFeedbackTypeformUrl('help-center'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
@@ -21,20 +21,42 @@
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
|
||||
@update:model-value="(val) => (selectedSpeed = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in speedOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
:model-value="
|
||||
selectedAnimation != null ? String(selectedAnimation) : undefined
|
||||
"
|
||||
@update:model-value="(val) => (selectedAnimation = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="anim in animations"
|
||||
:key="anim.index"
|
||||
:value="String(anim.index)"
|
||||
>
|
||||
{{ anim.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-xs items-center gap-2 px-4">
|
||||
@@ -54,10 +76,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
@@ -5,20 +5,20 @@ import { ref } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="modelValue"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -15,21 +15,22 @@
|
||||
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
v-model="value"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
const {
|
||||
icon = 'pi-expand',
|
||||
@@ -47,6 +48,12 @@ const {
|
||||
const value = defineModel<number>()
|
||||
const showSlider = ref(false)
|
||||
|
||||
const sliderValue = computed(() => [value.value ?? min])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) value.value = val[0]
|
||||
}
|
||||
|
||||
const toggleSlider = () => {
|
||||
showSlider.value = !showSlider.value
|
||||
}
|
||||
|
||||
@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
|
||||
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:aria-label="ariaLabel"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,34 +2,46 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="cameraType">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="cam in cameras"
|
||||
:key="cam.value"
|
||||
:value="cam.value"
|
||||
>
|
||||
{{ cam.title }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
:model-value="fovSliderValue"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
:aria-label="t('load3d.fov')"
|
||||
@update:model-value="onFovUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -41,4 +53,10 @@ const cameras = [
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const fovSliderValue = computed(() => [fov.value ?? 10])
|
||||
|
||||
function onFovUpdate(val: number[] | undefined) {
|
||||
if (val?.length) fov.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||