Compare commits
38 Commits
ecs-vue-ho
...
kishore/oa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f789e869e0 | ||
|
|
9222f057c4 | ||
|
|
ca6b699a3d | ||
|
|
635a75871b | ||
|
|
8264105116 | ||
|
|
4504256f11 | ||
|
|
1290bbd359 | ||
|
|
7ddf71d91b | ||
|
|
74caeb0b0b | ||
|
|
ced7c93e63 | ||
|
|
759ed3d4e2 | ||
|
|
5d53e75d23 | ||
|
|
d23e86d9a4 | ||
|
|
d901c63a0b | ||
|
|
5ca9f3e7e6 | ||
|
|
6d5fa743b3 | ||
|
|
603dd3eb4e | ||
|
|
d767a325a2 | ||
|
|
39b2bb5eab | ||
|
|
c643438601 | ||
|
|
02e1ba2968 | ||
|
|
15b8771cc2 | ||
|
|
e68d50e677 | ||
|
|
48b5e0165a | ||
|
|
fe1de3b254 | ||
|
|
1c2ae70343 | ||
|
|
8f68be5699 | ||
|
|
653ef1a4f0 | ||
|
|
c16052e2e3 | ||
|
|
3e94459340 | ||
|
|
ca54877f9d | ||
|
|
a4faaa0159 | ||
|
|
8108967d49 | ||
|
|
0ef98de8eb | ||
|
|
88866fc564 | ||
|
|
1f4a4af079 | ||
|
|
c8c0e53865 | ||
|
|
c8360a092f |
@@ -19,15 +19,26 @@ reviews:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
|
||||
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
Use only PR metadata already available in the review context:
|
||||
- the PR title
|
||||
- commit subjects in this PR
|
||||
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
|
||||
- the PR description.
|
||||
Do not rely on shell commands.
|
||||
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
|
||||
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
Fail if all of the following are true:
|
||||
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
|
||||
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
|
||||
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
|
||||
|
||||
Pass otherwise.
|
||||
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
|
||||
88
.github/workflows/ci-tests-extension-api.yaml
vendored
@@ -1,88 +0,0 @@
|
||||
# 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.
|
||||
@@ -32,16 +32,34 @@ test.describe('Careers page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
const rolesSection = page.getByTestId('careers-roles')
|
||||
await rolesSection.scrollIntoViewIfNeeded()
|
||||
await expect(rolesSection).toBeVisible()
|
||||
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
|
||||
const engineeringButton = page.getByRole('button', {
|
||||
name: 'ENGINEERING',
|
||||
exact: true
|
||||
})
|
||||
|
||||
// RolesSection is hydrated via `client:visible`. Once the button responds
|
||||
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
|
||||
// the locator logic is in effect.
|
||||
await expect(async () => {
|
||||
await engineeringButton.click()
|
||||
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
|
||||
timeout: 1_000
|
||||
})
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
const engineeringSection = page.locator('#careers-dept-engineering')
|
||||
await expect(engineeringSection).toBeInViewport()
|
||||
|
||||
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
61
apps/website/e2e/content-section.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
|
||||
const LAST_SECTION_HASH = '#contact'
|
||||
|
||||
test.describe(
|
||||
'ContentSection scroll-spy @smoke',
|
||||
{
|
||||
annotation: [
|
||||
{
|
||||
type: 'issue',
|
||||
description:
|
||||
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
description:
|
||||
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
|
||||
}
|
||||
]
|
||||
},
|
||||
() => {
|
||||
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
|
||||
|
||||
test('activates the last badge when user scrolls to the bottom', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/privacy-policy')
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const badges = sidebarNav.getByRole('button')
|
||||
const lastBadge = badges.last()
|
||||
|
||||
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.documentElement.scrollHeight)
|
||||
)
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const lastBadge = sidebarNav.getByRole('button').last()
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,27 +1,71 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { demos, getNextDemo } from '../src/config/demos'
|
||||
import { t } from '../src/i18n/translations'
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
for (const demo of demos) {
|
||||
const nextDemo = getNextDemo(demo.slug)
|
||||
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/demos/${demo.slug}`)
|
||||
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t(demo.title, 'en'))
|
||||
|
||||
const ogImage = page.locator('head meta[property="og:image"]')
|
||||
await expect(ogImage).toHaveAttribute(
|
||||
'content',
|
||||
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
|
||||
)
|
||||
|
||||
const iframe = page.locator(
|
||||
`iframe[title*="${t('demos.embed.label', 'en')}"]`
|
||||
)
|
||||
await expect(iframe).toBeAttached()
|
||||
await expect(iframe).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(escapeRegExp(demo.arcadeId))
|
||||
)
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'en')).first()
|
||||
).toBeVisible()
|
||||
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
|
||||
await expect(nextThumb).toBeAttached()
|
||||
await expect(nextThumb).toBeVisible()
|
||||
const naturalWidth = await nextThumb.evaluate(
|
||||
(img) => (img as HTMLImageElement).naturalWidth
|
||||
)
|
||||
expect(naturalWidth).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/zh-CN/demos/${demo.slug}`)
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
|
||||
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'zh-CN')).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
@@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('SocialProofBar seamless marquee', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test('mobile forward marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
|
||||
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
|
||||
)
|
||||
expectSeamlessReverseLoop(geometry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop SocialProofBar @smoke', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('desktop marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-desktop"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
})
|
||||
|
||||
type MarqueeGeometry = {
|
||||
copyWidths: number[]
|
||||
startPositions: number[]
|
||||
endPositions: number[]
|
||||
}
|
||||
|
||||
async function measureMarqueeLoopGeometry(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<MarqueeGeometry> {
|
||||
await page.locator(selector).first().waitFor()
|
||||
return page.evaluate((sel) => {
|
||||
const tracks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(sel)
|
||||
).slice(0, 2)
|
||||
const firstAnimation = tracks[0]?.getAnimations()[0]
|
||||
if (!firstAnimation) {
|
||||
throw new Error(`No CSS animation found on ${sel}`)
|
||||
}
|
||||
const duration = firstAnimation.effect?.getTiming().duration
|
||||
if (typeof duration !== 'number' || duration <= 1) {
|
||||
throw new Error(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
}
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
(track) => track.getBoundingClientRect().width
|
||||
)
|
||||
setAllTimes(duration - 0.1)
|
||||
const endPositions = readX()
|
||||
return { copyWidths, startPositions, endPositions }
|
||||
}, selector)
|
||||
}
|
||||
|
||||
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
|
||||
const { copyWidths } = geometry
|
||||
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
|
||||
expect(copyWidths[0]).toBeGreaterThan(0)
|
||||
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
|
||||
// (when copy 1 jumps back to its start position) is visually indistinguishable.
|
||||
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
|
||||
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
|
||||
}
|
||||
|
||||
BIN
apps/website/public/images/demos/community-workflows-og.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
apps/website/public/images/demos/community-workflows-thumb.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 20 KiB |
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -13,24 +16,72 @@ const { locale = 'en', departments = [] } = defineProps<{
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
const categories = computed(() =>
|
||||
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
|
||||
const activeCategory = ref('')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
|
||||
let isScrolling = false
|
||||
let pendingFrame = 0
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
if (isScrolling) return
|
||||
const sections = sectionRefs.value as HTMLElement[]
|
||||
if (sections.length === 0) return
|
||||
|
||||
let active = sections[0]
|
||||
for (const el of sections) {
|
||||
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
|
||||
active = el
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
activeCategory.value = active.id.replace(/^careers-dept-/, '')
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingFrame !== 0) return
|
||||
pendingFrame = requestAnimationFrame(pickActiveSection)
|
||||
}
|
||||
|
||||
onMounted(pickActiveSection)
|
||||
useEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
useEventListener('resize', scheduleUpdate, { passive: true })
|
||||
|
||||
function scrollToDepartment(deptKey: string) {
|
||||
activeCategory.value = deptKey
|
||||
isScrolling = true
|
||||
const el = document.getElementById(deptElementId(deptKey))
|
||||
if (!el) {
|
||||
isScrolling = false
|
||||
return
|
||||
}
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
pickActiveSection()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:model-value="activeCategory"
|
||||
class="mt-4"
|
||||
@update:model-value="scrollToDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in filteredDepartments"
|
||||
v-for="dept in visibleDepartments"
|
||||
:id="deptElementId(dept.key)"
|
||||
:ref="sectionRefs.set"
|
||||
:key="dept.key"
|
||||
class="mb-12 last:mb-0"
|
||||
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
|
||||
>
|
||||
<SectionLabel>
|
||||
{{ dept.name }}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useTemplateRefsList
|
||||
} from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
@@ -40,13 +44,25 @@ const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sectionRefs,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
@@ -58,22 +74,39 @@ useIntersectionObserver(
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = sections[sections.length - 1]?.id
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
onMounted(activateLastIfAtBottom)
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
}
|
||||
onComplete: clearScrollLock
|
||||
})
|
||||
return
|
||||
}
|
||||
isScrolling = false
|
||||
clearScrollLock()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
v-if="i > 0"
|
||||
src="/icons/node-union.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -72,7 +72,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,23 +14,28 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2]
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
aspectRatio = 16 / 9,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
aspectRatio?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -24,7 +26,8 @@ const loaded = ref(false)
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
:style="{ aspectRatio }"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
|
||||
@@ -276,29 +276,6 @@ onUnmounted(() => {
|
||||
fill="#211927"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
x="300"
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
y2="600"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ interface Demo {
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
/**
|
||||
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
|
||||
* landscape screencast). Sizes the embed container to match so rounded
|
||||
* corners hug the content instead of empty letterbox space. Source from
|
||||
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
|
||||
* invert it). Defaults to 16/9 if omitted.
|
||||
*/
|
||||
readonly aspectRatio?: number
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'community-workflows',
|
||||
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.community-workflows.title',
|
||||
description: 'demos.community-workflows.description',
|
||||
transcript: 'demos.community-workflows.transcript',
|
||||
ogImage: '/images/demos/community-workflows-og.png',
|
||||
thumbnail: '/images/demos/community-workflows-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'community', 'workflow', 'hub'],
|
||||
publishedDate: '2026-05-04',
|
||||
modifiedDate: '2026-05-04',
|
||||
aspectRatio: 1.931
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -3570,6 +3570,20 @@ const translations = {
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.community-workflows.title': {
|
||||
en: 'Explore and Use a Community Workflow from the Hub',
|
||||
'zh-CN': '探索并使用社区工作流'
|
||||
},
|
||||
'demos.community-workflows.description': {
|
||||
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
|
||||
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
|
||||
},
|
||||
'demos.community-workflows.transcript': {
|
||||
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
|
||||
@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
client:load
|
||||
/>
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
@@ -101,13 +101,13 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -115,11 +115,15 @@
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-marquee-reverse {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple-effect {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -470,6 +470,7 @@ const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
initialFeatureFlags: Record<string, unknown>
|
||||
initialSettings: Record<string, unknown>
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
comfyFiles: ComfyFiles
|
||||
@@ -477,6 +478,10 @@ export const comfyPageFixture = base.extend<{
|
||||
// Allows configuring feature flags for tests with before initial setup:
|
||||
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
|
||||
initialFeatureFlags: [{}, { option: true }],
|
||||
// Allows seeding user settings before initial page load:
|
||||
// `test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })`. Merged on top of
|
||||
// the fixture's defaults so per-test values win.
|
||||
initialSettings: [{}, { option: true }],
|
||||
|
||||
page: async ({ page, browserName }, use) => {
|
||||
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
|
||||
@@ -494,7 +499,11 @@ export const comfyPageFixture = base.extend<{
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
|
||||
comfyPage: async (
|
||||
{ page, request, initialFeatureFlags, initialSettings },
|
||||
use,
|
||||
testInfo
|
||||
) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = testInfo
|
||||
@@ -529,7 +538,8 @@ export const comfyPageFixture = base.extend<{
|
||||
// Disable errors tab to prevent missing model detection from
|
||||
// rendering error indicators on nodes during unrelated tests.
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false,
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
|
||||
...initialSettings
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -82,7 +82,7 @@ export class Topbar {
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
return this.page.locator('.p-dialog-content input')
|
||||
return this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
saveWorkflow(workflowName: string): Promise<void> {
|
||||
@@ -116,9 +116,9 @@ export class Topbar {
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
const confirmationDialog = this.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const confirmationDialog = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
if (await confirmationDialog.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
PromptResponse
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
@@ -230,6 +234,16 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress_state` WS event with per-node execution state. */
|
||||
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'progress_state',
|
||||
data: { prompt_id: jobId, nodes }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
|
||||
@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -14,6 +14,7 @@ export class VueNodeFixture {
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -25,6 +26,7 @@ export class VueNodeFixture {
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
47
browser_tests/tests/customNodeLocales.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { CustomNodesI18n } from '@/schemas/apiSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const NODE_TYPE = 'DevToolsNodeWithStringInput'
|
||||
const LOCALIZED_ZH = '本地化字符串输入 (ZH)'
|
||||
|
||||
const i18nResponse: CustomNodesI18n = {
|
||||
zh: {
|
||||
nodeDefs: {
|
||||
[NODE_TYPE]: { display_name: LOCALIZED_ZH }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Custom node locales loading',
|
||||
{ tag: ['@ui', '@vue-nodes'] },
|
||||
() => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/i18n', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(i18nResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Regression test for PR #7214 (issue #7025): custom-node i18n data was
|
||||
// clobbered when a non-English locale was lazily loaded, so nodes from
|
||||
// custom packs lost their translated display_name on locale switch.
|
||||
test('preserves custom-node /api/i18n translation through lazy locale load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.addNode(NODE_TYPE)
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH)).toHaveCount(
|
||||
1
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.keyboard.press('Control+s')
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
// The Save As dialog should appear
|
||||
const saveDialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(saveDialog).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const overwriteDialog = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,9 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const DEPRECATED_NODE_TYPE = 'ImageBatch'
|
||||
const API_NODE_TYPE = 'FluxProUltraImageNode'
|
||||
|
||||
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -141,3 +144,73 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
for (const vueEnabled of [false, true] as const) {
|
||||
const renderer = vueEnabled ? 'vue' : 'classic'
|
||||
const tag = vueEnabled
|
||||
? ['@vue-nodes', '@screenshot', '@node']
|
||||
: ['@screenshot', '@node']
|
||||
|
||||
test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
})
|
||||
|
||||
for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
|
||||
test(`renders deprecated node with mode=${mode}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
|
||||
mode
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`node-lifecycle-${mode}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe(`API pricing badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_NODE_TYPE)
|
||||
})
|
||||
|
||||
for (const enabled of [true, false] as const) {
|
||||
test(`renders api node with showApiPricing=${enabled}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.ShowApiPricing',
|
||||
enabled
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -21,9 +21,8 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
|
||||
const nodeId = String(loadImageNode.id)
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.image-preview')
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
|
||||
@@ -44,6 +43,25 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('hides mask and download buttons when image is missing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/load_image_widget_missing_file'
|
||||
)
|
||||
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
|
||||
await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
|
||||
await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows image context menu options', async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
211
browser_tests/tests/wsReconnectStaleJob.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type {
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
const EXECUTING_CLASS = /outline-node-stroke-executing/
|
||||
|
||||
const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
|
||||
const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
|
||||
|
||||
function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
|
||||
return {
|
||||
jobs,
|
||||
pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function mockJobsRoute(
|
||||
comfyPage: ComfyPage,
|
||||
pattern: RegExp,
|
||||
body: string,
|
||||
status: number = 200
|
||||
): Promise<() => number> {
|
||||
let count = 0
|
||||
await comfyPage.page.route(pattern, async (route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body
|
||||
})
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
const emptyJobsBody = JSON.stringify(jobsResponse([]))
|
||||
|
||||
type Scenario = {
|
||||
name: string
|
||||
/** Built per-test so it can incorporate the runtime-assigned jobId. */
|
||||
queueBody: (jobId: string) => string
|
||||
/** Whether the active job state should still be reflected after reconnect. */
|
||||
expectsActiveAfter: boolean
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: 'clears stale active job when queue is empty after reconnect',
|
||||
queueBody: () => emptyJobsBody,
|
||||
expectsActiveAfter: false
|
||||
},
|
||||
{
|
||||
name: 'preserves active job when the job is still in the queue',
|
||||
queueBody: (jobId) =>
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
),
|
||||
expectsActiveAfter: true
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Stub the queue/history endpoints per `scenario`, close the WS, and wait
|
||||
* for the auto-reconnect to issue a fresh queue fetch.
|
||||
*/
|
||||
async function triggerReconnect(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute,
|
||||
scenario: Scenario,
|
||||
jobId: string
|
||||
): Promise<void> {
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
const queueFetches = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
scenario.queueBody(jobId)
|
||||
)
|
||||
const fetchesBeforeClose = queueFetches()
|
||||
await ws.close()
|
||||
await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
|
||||
}
|
||||
|
||||
test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
|
||||
test.describe('app mode skeleton', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
// Skeleton visibility is the deterministic sync point: it appears
|
||||
// once both `storeJob` (HTTP) and `executionStart` (WS) have been
|
||||
// processed, regardless of arrival order.
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
} else {
|
||||
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves active job when the queue endpoint fails on reconnect', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
|
||||
// Prime queueStore.runningTasks with the active job — a WS status
|
||||
// event drives GraphView.onStatus -> queueStore.update().
|
||||
const primer = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
)
|
||||
)
|
||||
exec.status(1)
|
||||
await expect.poll(primer).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Swap to a failing handler so the reconnect-driven fetch 500s.
|
||||
// The fix should preserve runningTasks from the priming call rather
|
||||
// than overwriting it with empty/error state.
|
||||
await comfyPage.page.unroute(QUEUE_ROUTE)
|
||||
const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
|
||||
|
||||
const before = failed()
|
||||
await ws.close()
|
||||
await expect.poll(failed).toBeGreaterThan(before)
|
||||
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
// The executing outline lives on the outer `[data-node-id]`
|
||||
// container, not the inner wrapper.
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
|
||||
await expect(ksamplerNode).toBeVisible()
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
exec.progressState(jobId, {
|
||||
[KSAMPLER_NODE]: {
|
||||
value: 0,
|
||||
max: 1,
|
||||
state: 'running',
|
||||
node_id: KSAMPLER_NODE,
|
||||
display_node_id: KSAMPLER_NODE,
|
||||
prompt_id: jobId
|
||||
}
|
||||
})
|
||||
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
} else {
|
||||
await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [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 |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [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 |
|
||||
|
||||
|
||||
328
docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 9. Subgraph promoted widgets use linked inputs
|
||||
|
||||
Date: 2026-05-05
|
||||
|
||||
Appendices:
|
||||
|
||||
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
|
||||
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
|
||||
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Subgraph widget promotion historically had two overlapping representations:
|
||||
|
||||
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
|
||||
2. linked subgraph inputs, where an interior widget-bearing input is exposed
|
||||
through the subgraph boundary.
|
||||
|
||||
This created ambiguous ownership. Runtime value reads could collapse to an
|
||||
interior source widget, while host `widgets_values` could also carry an
|
||||
exterior value. Multiple host instances of the same subgraph could therefore
|
||||
stomp one another, and serialization could mutate interior widgets as a
|
||||
persistence carrier for exterior values.
|
||||
|
||||
The ECS widget migration makes that ambiguity more expensive: widgets are
|
||||
becoming entities with component state keyed by stable entity identity, and
|
||||
subgraphs are modeled as graph boundary structure rather than a separate
|
||||
promotion-specific entity kind.
|
||||
|
||||
## Decision
|
||||
|
||||
Promoted widgets are represented only as standard linked `SubgraphInput`
|
||||
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
|
||||
input on a host `SubgraphNode`. The interior source widget supplies schema,
|
||||
type, options, tooltip, and default metadata, but it is not the owner of the
|
||||
host value.
|
||||
|
||||
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
|
||||
promoted widget. It is a separate preview-exposure system because it has no
|
||||
host-owned widget value, does not feed prompt serialization, and often points at
|
||||
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
|
||||
|
||||
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
|
||||
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
|
||||
those entries. The standard serialized representation is the existing subgraph
|
||||
interface/input form plus host-node `widgets_values`.
|
||||
|
||||
Display-only preview exposures use their own host-node-scoped serialized entry,
|
||||
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
|
||||
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
|
||||
language, not widget language:
|
||||
|
||||
```ts
|
||||
type PreviewExposure = {
|
||||
name: string
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
```
|
||||
|
||||
Host-node scope preserves current behavior where different instances of the
|
||||
same subgraph can choose different exposed previews.
|
||||
|
||||
The entry intentionally stores only host preview identity and source locator
|
||||
identity. `name` is the host-scoped stable identity for this preview exposure,
|
||||
analogous to `SubgraphInput.name`; it is not a display label. It is generated
|
||||
with existing collision behavior, such as `nextUniqueName(...)`, when an
|
||||
exposure is created. Media type, display labels, titles, image/video/audio URLs,
|
||||
and other runtime preview details are derived from the current graph and output
|
||||
state. Array order is the canonical display order. Preview exposures do not get
|
||||
a separate persisted `label` in this slice; if a future rename UX needs one, it
|
||||
should follow the same rule as subgraph inputs: `name` is identity and `label`
|
||||
is display-only.
|
||||
|
||||
Preview exposures are persisted user choices after creation. Packing nodes into
|
||||
a subgraph may auto-add recommended preview exposures for supported output
|
||||
nodes, and users may explicitly add or remove additional preview exposures
|
||||
afterward. Normal load/save does not re-derive previews from node type alone,
|
||||
because that would make old workflows change when support for new preview node
|
||||
types is added. Unresolved preview exposures remain persisted and inert;
|
||||
automatic cleanup does not prune them. They are removed only by explicit user
|
||||
action or by destruction/unpacking of the owning host.
|
||||
|
||||
Preview exposures compose through nested subgraph hosts by chaining immediate
|
||||
boundaries. If an outer subgraph wants to show a preview exposed by an inner
|
||||
subgraph host, the outer `previewExposures` entry points at the immediate inner
|
||||
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
|
||||
identity, not the deepest interior preview name. Runtime preview resolution may
|
||||
then follow the inner host's own preview exposures to find media. Canonical JSON
|
||||
does not persist flattened deep paths, because deep paths would couple host UI
|
||||
state to private nested graph internals.
|
||||
|
||||
## Identity and value ownership
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- Host-scoped identity means the host `SubgraphNode` instance within its
|
||||
containing `graphScope`; the interior source node is not the state or
|
||||
persistence owner.
|
||||
- `SubgraphInput.name` is the stable internal identity.
|
||||
- `SubgraphInput.label` / `localized_name` are display-only.
|
||||
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
|
||||
persisted widget value key.
|
||||
- Source node/widget identity remains metadata for diagnostics, missing-model
|
||||
lookup, schema projection, and migration only.
|
||||
- The host/exterior value wins over the interior/source value during repair,
|
||||
persistence, and prompt serialization.
|
||||
|
||||
This follows the existing widget/slot convention: `name` is identity, `label`
|
||||
is display.
|
||||
|
||||
Promoted-widget value state is a host-scoped sparse overlay over source-widget
|
||||
metadata and defaults. The source widget remains the schema/default provider;
|
||||
host value state is materialized only when the exterior value differs from the
|
||||
effective source default or when restored from persisted host state. Canonical
|
||||
save/load must not eagerly mirror source defaults or use interior widgets as
|
||||
persistence carriers.
|
||||
|
||||
## Forward migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
|
||||
|
||||
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
|
||||
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
|
||||
not quarantined.
|
||||
3. Build a multi-pass association map before mutation:
|
||||
- normalized legacy proxy entry;
|
||||
- projected legacy promoted-widget order;
|
||||
- host `widgets_values` value, preserving sparse holes;
|
||||
- repair strategy or failure reason;
|
||||
- whether the entry is a value widget or display-only preview exposure.
|
||||
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
|
||||
is configured.
|
||||
5. On flush, re-resolve against current graph state, because clone/paste/load
|
||||
flows may have remapped or created nodes and links.
|
||||
6. If already represented by a linked `SubgraphInput`, consider the legacy
|
||||
entry resolved and consume it.
|
||||
7. Otherwise repair through existing subgraph input/link systems.
|
||||
8. If the entry is display-only preview surfacing, migrate it into the separate
|
||||
preview-exposure representation instead of creating a linked `SubgraphInput`.
|
||||
9. If value-widget repair fails, write inert quarantine metadata and warn.
|
||||
|
||||
The repair is idempotent. Pending plans store tuple/value data and re-check the
|
||||
current graph before applying mutations.
|
||||
|
||||
Legacy entries are classified as preview exposures when either:
|
||||
|
||||
- the legacy source name starts with `$$`; or
|
||||
- the source node resolves to a matching pseudo-preview widget, such as a
|
||||
`serialize: false` preview/video/audio UI widget.
|
||||
|
||||
Everything else is treated as a value-widget promotion candidate. An unresolved
|
||||
preview-shaped entry remains inert at runtime and is still persisted, because
|
||||
preview-capable pseudo-widgets and output media can be removed and re-added
|
||||
dynamically. It is not quarantined because it has no user value to preserve. A
|
||||
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
|
||||
failure and follows the quarantine path unless it can resolve to a
|
||||
pseudo-preview widget.
|
||||
|
||||
## Proxy widget error quarantine
|
||||
|
||||
Valid legacy entries that cannot be repaired are persisted in
|
||||
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
|
||||
not hydrate runtime promoted widgets, do not participate in execution, and are
|
||||
not used for app-mode/favorites identity.
|
||||
|
||||
Quarantine entries preserve enough information to avoid data loss and support
|
||||
future tooling:
|
||||
|
||||
```ts
|
||||
type ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: ProxyWidgetTuple
|
||||
reason:
|
||||
| 'missingSourceNode'
|
||||
| 'missingSourceWidget'
|
||||
| 'missingSubgraphInput'
|
||||
| 'ambiguousSubgraphInput'
|
||||
| 'unlinkedSourceWidget'
|
||||
| 'primitiveBypassFailed'
|
||||
hostValue?: TWidgetValue
|
||||
attemptedAtVersion: 1
|
||||
}
|
||||
```
|
||||
|
||||
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
|
||||
Workflow-level promotion/value intent is preserved by
|
||||
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
|
||||
|
||||
## Primitive-node repair
|
||||
|
||||
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
|
||||
serve nearly the same purpose as subgraph inputs: they provide a widget value to
|
||||
one or more target widget inputs. The migration repairs this expected legacy
|
||||
shape in the first migration rather than quarantining it by default.
|
||||
|
||||
Primitive repair:
|
||||
|
||||
- coalesces exact duplicate legacy entries during planning;
|
||||
- uses the primitive node's user title as the base input name when the node was
|
||||
renamed, otherwise the primitive output widget name;
|
||||
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
|
||||
- uses the existing primitive merge/config compatibility logic;
|
||||
- creates one `SubgraphInput` for the primitive fanout;
|
||||
- reconnects every former primitive output target to that input in target
|
||||
order, using standard connect/disconnect APIs;
|
||||
- applies the host value when one exists, otherwise seeds from the source
|
||||
primitive value;
|
||||
- leaves the primitive node and its widget value in place, but disconnected and
|
||||
inert.
|
||||
|
||||
Primitive repair is all-or-quarantine. If any target cannot be validated or
|
||||
reconnected, the migration does not leave a partial rewrite; it quarantines the
|
||||
entry with `hostValue` and logs the reason.
|
||||
|
||||
## Serialization
|
||||
|
||||
After repair/quarantine:
|
||||
|
||||
- `properties.proxyWidgets` is omitted for repaired entries;
|
||||
- display-only preview entries are omitted from `properties.proxyWidgets` and
|
||||
emitted through `properties.previewExposures`;
|
||||
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
|
||||
- preview exposures do not carry quarantine values because they do not own user
|
||||
values; unresolved preview exposures remain inert in `previewExposures`;
|
||||
- host `widgets_values` contains host-owned values only for canonical host
|
||||
widgets, not source-owned defaults or interior persistence copies;
|
||||
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
|
||||
- array-form `widgets_values` remains for now.
|
||||
|
||||
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
|
||||
preview rendering, but they do not create prompt inputs, do not create
|
||||
`widgets_values`, do not alter node execution order, do not become executable
|
||||
graph edges, and do not participate in prompt serialization. Runtime mapping
|
||||
from backend `display_node`/output messages to a host preview exposure is a UI
|
||||
projection only.
|
||||
|
||||
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
|
||||
values into connected interior widgets is removed. A temporary TODO should mark
|
||||
that removal point until the migration is proven stable. Host values are
|
||||
serialized through standard subgraph-input widgets instead.
|
||||
|
||||
Longer term, `widgets_values` should move from array order to an object/map
|
||||
keyed by stable widget name, but that migration is out of scope for this
|
||||
decision.
|
||||
|
||||
## App mode, builder, and favorites
|
||||
|
||||
The runtime migration and UI identity migration ship in the same slice. The UI
|
||||
must not persist promoted selections by source node/widget identity after this
|
||||
change.
|
||||
|
||||
Canonical UI identity is:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
Legacy source-identity selections are migrated when they resolve through the
|
||||
standard input created or confirmed by the migration. Unresolved selections are
|
||||
dropped with a warning.
|
||||
|
||||
Preview exposure output selections are also host-scoped and must not persist
|
||||
interior source node identity. Canonical preview/output identity is:
|
||||
|
||||
```ts
|
||||
type PreviewExposureUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
previewName: string
|
||||
}
|
||||
```
|
||||
|
||||
The UI references the explicit preview exposure itself. This keeps subgraphs
|
||||
opaque: consumers select the host boundary contract, not the interior node that
|
||||
currently supplies media. Legacy output selections that refer to interior
|
||||
preview source nodes may migrate if they resolve to a preview-exposure chain;
|
||||
otherwise they are dropped with `console.warn`. There is no separate preview UI
|
||||
quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
|
||||
## Considered options
|
||||
|
||||
### Keep `proxyWidgets` as canonical serialized topology
|
||||
|
||||
Rejected. This preserves two representations for the same concept and keeps
|
||||
source-widget identity in the value-ownership path.
|
||||
|
||||
### Preserve bare promoted widgets as degraded runtime state
|
||||
|
||||
Rejected. This would avoid some migration complexity, but it perpetuates the
|
||||
ambiguity that caused host/source value bugs and makes ECS identity less clear.
|
||||
|
||||
### Quarantine primitive-node promotions by default
|
||||
|
||||
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
|
||||
quarantining them would break users unnecessarily. They are repaired by bypassing
|
||||
the primitive node when the repair can be validated all-or-nothing.
|
||||
|
||||
### Migrate `widgets_values` to object/map form now
|
||||
|
||||
Rejected for this slice. Name-keyed object form is the desired long-term
|
||||
direction, but combining it with the promotion migration increases blast radius
|
||||
for existing workflow consumers that still assume array order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Promoted widget values become host-instance-owned and ECS-compatible.
|
||||
- Source widgets remain metadata/default providers, not persistence carriers.
|
||||
- Legacy workflows are repaired toward one standard representation.
|
||||
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
|
||||
runtime promotion.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Appendix: Before and after flows
|
||||
|
||||
This appendix visualizes the ownership and migration flows described in
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
## Before: proxy widgets and linked inputs overlap
|
||||
|
||||
Historically, promoted widgets could be represented both as serialized
|
||||
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
|
||||
reads could collapse back to the interior source widget, while host
|
||||
`widgets_values` could also carry an exterior value for the same promoted UI.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
|
||||
promotionStore --> sourceWidget[Interior source widget]
|
||||
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
|
||||
sourceWidget --> hostWidget
|
||||
hostValues --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
hostWidget -. may copy value back .-> sourceWidget
|
||||
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
|
||||
|
||||
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
|
||||
class proxyWidgets,promotionStore legacy
|
||||
class sourceWidget,hostValues ambiguous
|
||||
class linkedInput,hostWidget canonical
|
||||
```
|
||||
|
||||
Key problems in the old flow:
|
||||
|
||||
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
|
||||
the same promotion.
|
||||
- Interior source widgets supplied both schema metadata and, in some flows,
|
||||
persisted host values.
|
||||
- Multiple host instances of the same subgraph could stomp one another through
|
||||
the shared interior widget value.
|
||||
- Display-only previews were mixed into widget-promotion language even though
|
||||
they do not own values or feed prompt serialization.
|
||||
|
||||
## After: linked inputs are the promoted-widget boundary
|
||||
|
||||
Promoted value widgets are now represented only as standard linked
|
||||
`SubgraphInput` widgets. The source widget remains the schema/default provider,
|
||||
but the host `SubgraphNode` owns the promoted value.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
subgraphInterface --> subgraphInput[SubgraphInput.name]
|
||||
subgraphInput --> hostWidget[Host-scoped widget entity]
|
||||
hostValues --> hostWidget
|
||||
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
|
||||
schema --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
|
||||
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
|
||||
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
|
||||
sourceWidget -. no host value ownership .-> schema
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
|
||||
class sourceWidget,schema,diagnostics metadata
|
||||
class workflow,hostValues persisted
|
||||
```
|
||||
|
||||
Canonical ownership after the migration:
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- `SubgraphInput.name` is stable identity; labels and localized names are
|
||||
display-only.
|
||||
- Host values win during repair, persistence, and prompt serialization.
|
||||
- Source widgets provide metadata and defaults only.
|
||||
- Canonical saves omit repaired `properties.proxyWidgets` entries.
|
||||
|
||||
## Legacy load migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
|
||||
repair builds a plan before mutating graph state, then re-resolves against the
|
||||
current graph when node IDs and links are stable.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start[Load workflow] --> parse{Parse properties.proxyWidgets}
|
||||
parse -->|invalid raw data| invalid[console.error and ignore]
|
||||
parse -->|valid tuples| plan[Build repair plan]
|
||||
plan --> classify{Classify entry}
|
||||
|
||||
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
|
||||
valueRepair -->|yes| consume[Consume legacy proxy entry]
|
||||
valueRepair -->|no| repair[Repair through subgraph input/link systems]
|
||||
repair --> repairResult{Repair succeeded?}
|
||||
repairResult -->|yes| consume
|
||||
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
|
||||
|
||||
classify -->|primitive fanout| primitive[Validate all primitive targets]
|
||||
primitive --> primitiveResult{All targets reconnectable?}
|
||||
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
|
||||
primitiveRepair --> consume
|
||||
primitiveResult -->|no| quarantine
|
||||
|
||||
classify -->|display-only preview| preview[Create / keep previewExposures entry]
|
||||
preview --> consume
|
||||
|
||||
consume --> save[Canonical save]
|
||||
quarantine --> save
|
||||
save --> omit[Omit repaired entries from proxyWidgets]
|
||||
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
|
||||
save --> keepPreview[Persist previews in previewExposures]
|
||||
|
||||
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef error fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
|
||||
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
|
||||
class quarantine,keepQuarantine warn
|
||||
class invalid error
|
||||
```
|
||||
|
||||
## Preview exposures are separate from value widgets
|
||||
|
||||
Display-only previews, such as `$$canvas-image-preview`, are not promoted
|
||||
widgets. They have host-scoped serialized identity, but they do not create
|
||||
prompt inputs, do not create `widgets_values`, and do not own user values.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
|
||||
previewExposures --> exposure[PreviewExposure.name]
|
||||
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
|
||||
sourceLocator --> runtimePreview[Runtime preview/output state]
|
||||
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
|
||||
|
||||
exposure --> uiIdentity[hostNodeLocator + previewName]
|
||||
runtimePreview -. UI projection only .-> hostCanvas
|
||||
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
|
||||
previewExposures -. no value widget .-> noValue[No widgets_values entry]
|
||||
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
|
||||
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
|
||||
class noPrompt,noValue,noEdge noValue
|
||||
class hostNode persisted
|
||||
```
|
||||
|
||||
For nested subgraphs, preview exposures chain across immediate host boundaries
|
||||
instead of persisting flattened deep paths.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
|
||||
outerExposure --> innerHost[Immediate inner SubgraphNode]
|
||||
innerHost --> innerExposure[Inner previewExposures entry]
|
||||
innerExposure --> deepestPreview[Interior preview source]
|
||||
deepestPreview --> media[Resolved media]
|
||||
|
||||
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
|
||||
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class outerHost,innerHost boundary
|
||||
class outerExposure,innerExposure,deepestPreview,media preview
|
||||
class opaque note
|
||||
```
|
||||
|
||||
## Serialization summary
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
|
||||
canonical --> values[widgets_values for host-owned values]
|
||||
canonical --> previews[properties.previewExposures]
|
||||
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
|
||||
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
|
||||
|
||||
inputs --> valueWidgets[Promoted value widgets]
|
||||
values --> valueWidgets
|
||||
previews --> previewUi[Display-only preview UI]
|
||||
quarantine --> futureTooling[Future recovery tooling]
|
||||
|
||||
valueWidgets --> prompt[Prompt serialization]
|
||||
previewUi -. not serialized into prompt .-> prompt
|
||||
quarantine -. inert .-> prompt
|
||||
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class inputs,values,valueWidgets,prompt,canonical canonical
|
||||
class previews,previewUi,quarantine,futureTooling inert
|
||||
class noProxy removed
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Appendix: Removing `disambiguatingSourceNodeId`
|
||||
|
||||
This appendix explains where the existing promotion system needs
|
||||
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
|
||||
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
removes the pattern from promoted-widget identity.
|
||||
|
||||
## Why the disambiguator exists
|
||||
|
||||
The legacy promotion model identifies a promoted widget by source location:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetSource = {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
```
|
||||
|
||||
`sourceNodeId` is the immediate interior node visible from the host subgraph.
|
||||
That is not always the original widget owner. When promotions pass through
|
||||
nested subgraphs, two promoted widgets can have the same immediate
|
||||
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
|
||||
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
|
||||
can choose the right promoted view.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
|
||||
middleNode --> middleWidgetA[Promoted widget view: text]
|
||||
middleNode --> middleWidgetB[Promoted widget view: text]
|
||||
middleWidgetA --> leafA[Leaf source node 17 / widget text]
|
||||
middleWidgetB --> leafB[Leaf source node 42 / widget text]
|
||||
|
||||
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
|
||||
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
|
||||
middleWidgetA -. requires .-> oldKeyA
|
||||
middleWidgetB -. requires .-> oldKeyB
|
||||
|
||||
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
|
||||
|
||||
class outerHost host
|
||||
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
|
||||
class leafA,leafB leaf
|
||||
```
|
||||
|
||||
The disambiguator is therefore not a domain concept. It is compensating for an
|
||||
identity model that asks host UI state to identify private nested internals.
|
||||
|
||||
## Existing places that need it
|
||||
|
||||
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
|
||||
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
|
||||
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
|
||||
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
|
||||
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
|
||||
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
|
||||
|
||||
## New promoted-widget identity
|
||||
|
||||
ADR 0009 moves promoted value identity to the host boundary:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
The canonical widget is owned by a `SubgraphInput` on the host
|
||||
`SubgraphNode`. The host widget no longer needs to identify the deepest source
|
||||
node to preserve value identity. The source widget is consulted for schema,
|
||||
defaults, diagnostics, and migration, but it is not the value owner.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
|
||||
host --> inputB[SubgraphInput.name: negative_prompt]
|
||||
inputA --> hostWidgetA[Host-owned widget entity]
|
||||
inputB --> hostWidgetB[Host-owned widget entity]
|
||||
|
||||
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
|
||||
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
|
||||
|
||||
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
|
||||
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
|
||||
sourceA -. not part of host value key .-> identityA
|
||||
sourceB -. not part of host value key .-> identityB
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
|
||||
class sourceA,sourceB metadata
|
||||
```
|
||||
|
||||
This is the same rule the subgraph interface already uses: `name` is stable
|
||||
identity, and `label` / `localized_name` are display-only.
|
||||
|
||||
## How the new form removes each need
|
||||
|
||||
| Previous disambiguation site | New canonical replacement |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
|
||||
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
|
||||
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
|
||||
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
|
||||
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
|
||||
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
|
||||
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
|
||||
|
||||
## Boundary-by-boundary nested flow
|
||||
|
||||
The new form avoids flattened deep source paths. Each host boundary exposes its
|
||||
own named input, and the next outer host links to that immediate boundary
|
||||
contract.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
|
||||
innerInput --> innerHostWidget[Inner host-owned widget]
|
||||
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
|
||||
outerInput --> outerHostWidget[Outer host-owned widget]
|
||||
|
||||
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
|
||||
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
|
||||
leaf -. schema/default source .-> innerHostWidget
|
||||
leaf -. not persisted as outer value key .-> outerIdentity
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef source fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
|
||||
class leaf source
|
||||
```
|
||||
|
||||
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
|
||||
widgets no longer require a persisted leaf-node disambiguator at the outer host.
|
||||
If the user exposes both, the collision is resolved when the host inputs are
|
||||
created by assigning distinct input names with the existing unique-name
|
||||
behavior.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Appendix: System comparison
|
||||
|
||||
This appendix compares the legacy promoted-widget systems with the canonical
|
||||
linked-input model chosen by
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
|
||||
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
|
||||
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
|
||||
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
|
||||
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
|
||||
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
|
||||
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
|
||||
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
|
||||
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
|
||||
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
|
||||
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
|
||||
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
|
||||
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
|
||||
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
|
||||
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
|
||||
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
|
||||
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
|
||||
|
||||
## Practical migration summary
|
||||
|
||||
| Legacy shape | New result |
|
||||
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
|
||||
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
|
||||
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
|
||||
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
|
||||
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
|
||||
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
|
||||
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
|
||||
@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
|
||||
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
|
||||
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
|
||||
source node/widget identity is preserved only as migration and diagnostic
|
||||
metadata.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
source node/widget identity.
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# 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
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,213 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,221 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,313 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,724 +0,0 @@
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
source node/widget identity is migration and diagnostic metadata only.
|
||||
|
||||
## 2. WidgetValueStore
|
||||
|
||||
**File:** `src/stores/widgetValueStore.ts`
|
||||
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
identity (`host node locator + SubgraphInput.name`) instead of interior source
|
||||
identity.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
|
||||
@@ -404,26 +404,21 @@ Whichever candidate is chosen:
|
||||
instance-specific state beyond inputs — must remain reachable. This is a
|
||||
constraint, not a current requirement.
|
||||
|
||||
### Recommendation and decision criteria
|
||||
### Decision
|
||||
|
||||
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
|
||||
truth: promotion is adding a typed input to a function signature. The type
|
||||
system already handles widget creation for typed inputs. Building a parallel
|
||||
mechanism for "promoted widgets" is building a second, narrower version of
|
||||
something the system already does.
|
||||
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
chooses Candidate A for promoted value widgets. It eliminates an entire
|
||||
subsystem by recognizing a structural truth: promotion is adding a typed input
|
||||
to a function signature. The type system already handles widget creation for
|
||||
typed inputs. Building a parallel mechanism for "promoted widgets" is building
|
||||
a second, narrower version of something the system already does.
|
||||
|
||||
The cost of A is a migration path for existing `proxyWidgets` serialization. On
|
||||
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
|
||||
inputs and boundary links. This is a one-time ratchet conversion — once
|
||||
loaded and re-saved, the workflow uses the new format.
|
||||
|
||||
**Choose B if** the team determines that promoted widgets must remain
|
||||
visually or behaviorally distinct from normal input widgets in ways the type →
|
||||
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
|
||||
the current release cycle's capacity.
|
||||
|
||||
**Decision needed before** Phase 3 of the ECS migration, when systems are
|
||||
introduced and the widget/connectivity architecture solidifies.
|
||||
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
|
||||
into interface inputs and boundary links. Once loaded and re-saved, the workflow
|
||||
uses the new format. ADR 0009 separates display-only preview exposures from
|
||||
promoted value widgets; those previews use their own host-scoped serialized
|
||||
representation instead of linked `SubgraphInput` widgets.
|
||||
|
||||
---
|
||||
|
||||
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ---------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------ |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
|
||||
|
||||
The "ratchet conversion" pattern: load any supported format, normalize to the
|
||||
internal model. The system accepts old formats indefinitely but produces the
|
||||
current format on save.
|
||||
The migration pattern: load any supported format and normalize to the internal
|
||||
model. The system accepts old formats indefinitely but produces the current
|
||||
format on save.
|
||||
|
||||
### Widget identity at the boundary
|
||||
|
||||
@@ -511,13 +506,12 @@ SubgraphIO {
|
||||
}
|
||||
```
|
||||
|
||||
If Candidate A (connections-only promotion) is chosen: promoted widgets become
|
||||
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
|
||||
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
|
||||
migration). On save, `proxyWidgets` is no longer written.
|
||||
|
||||
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
|
||||
serialized in its current format.
|
||||
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
|
||||
widgets: they become interface inputs, serialized as additional `SubgraphIO`
|
||||
entries. On load, legacy value-widget `proxyWidgets` data is converted to
|
||||
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
|
||||
are no longer written. Display-only preview exposures use separate
|
||||
host-scoped `previewExposures` serialization.
|
||||
|
||||
### Backward-compatible loading contract
|
||||
|
||||
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
|
||||
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
|
||||
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
|
||||
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
|
||||
|
||||
|
||||
1
global.d.ts
vendored
@@ -5,6 +5,7 @@ declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
declare const __DEV_SERVER_COMFYUI_URL__: string
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
@@ -47,9 +47,6 @@
|
||||
"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
@@ -1,3 +0,0 @@
|
||||
docs-build/
|
||||
build/
|
||||
node_modules/
|
||||
@@ -1,50 +0,0 @@
|
||||
# @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
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
#!/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()
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
4
packages/ingest-types/src/types.gen.ts
generated
@@ -523,10 +523,6 @@ export type ImportPublishedAssetsRequest = {
|
||||
* IDs of published assets (inputs and models) to import.
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
*/
|
||||
share_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
3
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,8 +310,7 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
published_asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -10057,6 +10057,8 @@ export interface components {
|
||||
};
|
||||
progress: number;
|
||||
create_time: number;
|
||||
/** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */
|
||||
consumed_credit?: number;
|
||||
};
|
||||
TripoSuccessTask: {
|
||||
/** @enum {integer} */
|
||||
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -83,9 +85,11 @@ describe('formatUtil', () => {
|
||||
describe('video files', () => {
|
||||
it('should identify video extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('apple.m4v')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('film.avi')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('episode.mkv')).toBe('video')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -299,6 +303,42 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
@@ -557,7 +581,7 @@ const IMAGE_EXTENSIONS = [
|
||||
'tiff',
|
||||
'svg'
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
|
||||
@@ -11,6 +11,7 @@ declare global {
|
||||
const __ALGOLIA_APP_ID__: string
|
||||
const __ALGOLIA_API_KEY__: string
|
||||
const __USE_PROD_CONFIG__: boolean
|
||||
const __DEV_SERVER_COMFYUI_URL__: string
|
||||
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
const __IS_NIGHTLY__: boolean
|
||||
}
|
||||
@@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
|
||||
__ALGOLIA_APP_ID__: string
|
||||
__ALGOLIA_API_KEY__: string
|
||||
__USE_PROD_CONFIG__: boolean
|
||||
__DEV_SERVER_COMFYUI_URL__: string
|
||||
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||
__IS_NIGHTLY__: boolean
|
||||
window?: Record<string, unknown>
|
||||
@@ -37,6 +39,7 @@ globalWithDefines.__SENTRY_DSN__ = ''
|
||||
globalWithDefines.__ALGOLIA_APP_ID__ = ''
|
||||
globalWithDefines.__ALGOLIA_API_KEY__ = ''
|
||||
globalWithDefines.__USE_PROD_CONFIG__ = false
|
||||
globalWithDefines.__DEV_SERVER_COMFYUI_URL__ = ''
|
||||
globalWithDefines.__DISTRIBUTION__ = 'localhost'
|
||||
globalWithDefines.__IS_NIGHTLY__ = false
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
@@ -13,7 +13,23 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
overwrite: 'Overwrite',
|
||||
save: 'Save',
|
||||
no: 'No',
|
||||
ok: 'OK',
|
||||
close: 'Close'
|
||||
},
|
||||
desktopMenu: {
|
||||
reinstall: 'Reinstall'
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -24,10 +40,9 @@ describe('ConfirmationDialogContent', () => {
|
||||
})
|
||||
|
||||
function renderComponent(props: Partial<Props> = {}) {
|
||||
return render(ConfirmationDialogContent, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
},
|
||||
const user = userEvent.setup()
|
||||
render(ConfirmationDialogContent, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
message: 'Test message',
|
||||
type: 'default',
|
||||
@@ -35,6 +50,7 @@ describe('ConfirmationDialogContent', () => {
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('renders long messages without breaking layout', () => {
|
||||
@@ -44,42 +60,103 @@ describe('ConfirmationDialogContent', () => {
|
||||
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('renders the hint as a status alert when provided', () => {
|
||||
renderComponent({ hint: 'This action cannot be undone.' })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('This action cannot be undone.')
|
||||
})
|
||||
|
||||
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('does not render a status alert when hint is omitted', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderComponent({
|
||||
type: 'dirtyClose',
|
||||
denyLabel: 'Close anyway',
|
||||
onConfirm
|
||||
describe('button surface per type', () => {
|
||||
it("type='default' renders Cancel and Confirm", () => {
|
||||
renderComponent({ type: 'default' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Confirm' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
|
||||
it("type='delete' renders Cancel and Delete", () => {
|
||||
renderComponent({ type: 'delete' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
it("type='overwrite' renders Cancel and Overwrite", () => {
|
||||
renderComponent({ type: 'overwrite' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Overwrite' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='info' renders only OK (no Cancel)", () => {
|
||||
renderComponent({ type: 'info' })
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
|
||||
it('confirm callback receives true and closes the dialog', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderComponent({ type: 'dirtyClose', onConfirm })
|
||||
const { user } = renderComponent({ type: 'default', onConfirm })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back to "no" label when denyLabel is not provided', () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(screen.getByText('g.no')).toBeInTheDocument()
|
||||
describe('dirtyClose deny label', () => {
|
||||
it('uses the provided denyLabel for the deny button', () => {
|
||||
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
|
||||
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'No' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to "No" when denyLabel is not provided', () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm(false) when deny is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
type: 'dirtyClose',
|
||||
denyLabel: 'Close anyway',
|
||||
onConfirm
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('calls onConfirm(true) when save is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
<div
|
||||
v-if="hint"
|
||||
class="mt-2"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
role="status"
|
||||
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
|
||||
<span>{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-4">
|
||||
<div
|
||||
@@ -115,7 +113,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -44,24 +44,24 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function isSectionCollapsed(nodeId: string): boolean {
|
||||
function isSectionCollapsed(nodeId: NodeId): boolean {
|
||||
// Defaults to collapsed when not explicitly set by the user
|
||||
return collapseMap[nodeId] ?? true
|
||||
}
|
||||
|
||||
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
|
||||
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
|
||||
collapseMap[nodeId] = collapsed
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return searchedWidgetsSectionDataList.value.every(({ node }) =>
|
||||
isSectionCollapsed(String(node.id))
|
||||
isSectionCollapsed(node.id)
|
||||
)
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const { node } of widgetsSectionDataList.value) {
|
||||
setSectionCollapsed(String(node.id), collapse)
|
||||
setSectionCollapsed(node.id, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -101,7 +101,7 @@ async function searcher(query: string) {
|
||||
:key="node.id"
|
||||
:node
|
||||
:widgets
|
||||
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
|
||||
:collapse="isSectionCollapsed(node.id) && !isSearching"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
@@ -109,7 +109,7 @@ async function searcher(query: string) {
|
||||
"
|
||||
show-locate-button
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
@update:collapse="setSectionCollapsed(node.id, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -68,19 +68,19 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function isSectionCollapsed(nodeId: string): boolean {
|
||||
function isSectionCollapsed(nodeId: NodeId): boolean {
|
||||
// When not explicitly set, sections are collapsed if multiple nodes are selected
|
||||
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
|
||||
}
|
||||
|
||||
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
|
||||
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
|
||||
collapseMap[nodeId] = collapsed
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
|
||||
({ node }) => isSectionCollapsed(String(node.id))
|
||||
({ node }) => isSectionCollapsed(node.id)
|
||||
)
|
||||
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
|
||||
return hasAdvanced
|
||||
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const { node } of widgetsSectionDataList.value) {
|
||||
setSectionCollapsed(String(node.id), collapse)
|
||||
setSectionCollapsed(node.id, collapse)
|
||||
}
|
||||
advancedCollapsed.value = collapse
|
||||
}
|
||||
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
|
||||
:node
|
||||
:label
|
||||
:widgets
|
||||
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
|
||||
:collapse="isSectionCollapsed(node.id) && !isSearching"
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
@update:collapse="setSectionCollapsed(node.id, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
|
||||
|
||||
@@ -252,6 +252,20 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('Log Out')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('credits help icon (FE-617)', () => {
|
||||
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
|
||||
renderComponent()
|
||||
|
||||
const helpButton = screen.getByTestId('credits-info-button')
|
||||
expect(helpButton).toBeInTheDocument()
|
||||
expect(helpButton.tagName).toBe('BUTTON')
|
||||
expect(helpButton).toHaveAttribute(
|
||||
'aria-label',
|
||||
enMessages.credits.unified.tooltip
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
|
||||
|
||||
@@ -41,10 +41,16 @@
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<i
|
||||
<Button
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="mr-auto"
|
||||
:aria-label="$t('credits.unified.tooltip')"
|
||||
data-testid="credits-info-button"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
|
||||
@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
verifyMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
}
|
||||
// Cloud media scans always return isMissing: undefined pending
|
||||
// verification against the input-assets list.
|
||||
// Cloud media scans return pending for asset verification. OSS scans only
|
||||
// return pending for generated output/temp media.
|
||||
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingMedia.length) {
|
||||
void verifyAndAddPendingMedia(pendingMedia)
|
||||
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
|
||||
88
src/composables/useReconnectQueueRefresh.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
function makeJob(id: string, status: JobListItem['status']): JobListItem {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
update_time: 0,
|
||||
last_state_update: 0,
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getQueue: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
apiURL: vi.fn((p: string) => `/api${p}`)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useReconnectQueueRefresh', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
vi.mocked(api.getQueue).mockResolvedValue({ Running: [], Pending: [] })
|
||||
vi.mocked(api.getHistory).mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('forwards running+pending job ids to clearActiveJobIfStale', async () => {
|
||||
vi.mocked(api.getQueue).mockResolvedValue({
|
||||
Running: [makeJob('run-1', 'in_progress')],
|
||||
Pending: [makeJob('pend-1', 'pending'), makeJob('pend-2', 'pending')]
|
||||
})
|
||||
const executionStore = useExecutionStore()
|
||||
const clearSpy = vi
|
||||
.spyOn(executionStore, 'clearActiveJobIfStale')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const refresh = useReconnectQueueRefresh()
|
||||
await refresh()
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledTimes(1)
|
||||
expect(clearSpy).toHaveBeenCalledWith(
|
||||
new Set(['run-1', 'pend-1', 'pend-2'])
|
||||
)
|
||||
})
|
||||
|
||||
it('passes an empty set when the queue is genuinely empty', async () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const clearSpy = vi
|
||||
.spyOn(executionStore, 'clearActiveJobIfStale')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const refresh = useReconnectQueueRefresh()
|
||||
await refresh()
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('reuses the prior queue snapshot when the fetch fails, so a still-running job is not falsely cleared', async () => {
|
||||
vi.mocked(api.getQueue)
|
||||
.mockResolvedValueOnce({
|
||||
Running: [makeJob('run-1', 'in_progress')],
|
||||
Pending: []
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
const executionStore = useExecutionStore()
|
||||
const clearSpy = vi
|
||||
.spyOn(executionStore, 'clearActiveJobIfStale')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const refresh = useReconnectQueueRefresh()
|
||||
await refresh() // primes the store with run-1
|
||||
await refresh() // network failure here — store must not go empty
|
||||
|
||||
expect(clearSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
|
||||
})
|
||||
})
|
||||
25
src/composables/useReconnectQueueRefresh.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* After a WebSocket reconnect, refresh the queue from the server and clear
|
||||
* any active job that finished during the disconnect window. Returns the
|
||||
* handler so the caller can wire it to the `reconnected` api event.
|
||||
*
|
||||
* `update()` preserves the previous queue snapshot when the fetch fails, so
|
||||
* if the network is still flaky we reconcile against the last known good
|
||||
* state rather than an empty (and falsely "stale") set.
|
||||
*/
|
||||
export function useReconnectQueueRefresh() {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
return async function refreshOnReconnect() {
|
||||
await queueStore.update()
|
||||
const activeJobIds = new Set([
|
||||
...queueStore.runningTasks.map((t) => t.jobId),
|
||||
...queueStore.pendingTasks.map((t) => t.jobId)
|
||||
])
|
||||
executionStore.clearActiveJobIfStale(activeJobIds)
|
||||
}
|
||||
}
|
||||
26
src/config/comfyApi.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getComfyApiBaseUrlForEnvironment } from '@/config/comfyApi'
|
||||
|
||||
describe('comfy api config', () => {
|
||||
it('uses same-origin API calls for cloud local development', () => {
|
||||
expect(
|
||||
getComfyApiBaseUrlForEnvironment({
|
||||
isCloudDistribution: true,
|
||||
isDev: true,
|
||||
devServerComfyUIUrl: 'http://127.0.0.1:8188',
|
||||
useProdConfig: false
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('keeps staging API for non-local staging builds', () => {
|
||||
expect(
|
||||
getComfyApiBaseUrlForEnvironment({
|
||||
isCloudDistribution: true,
|
||||
isDev: false,
|
||||
useProdConfig: false
|
||||
})
|
||||
).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
})
|
||||
@@ -10,34 +10,68 @@ const STAGING_API_BASE_URL = 'https://stagingapi.comfy.org'
|
||||
const PROD_PLATFORM_BASE_URL = 'https://platform.comfy.org'
|
||||
const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
|
||||
|
||||
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
|
||||
? PROD_API_BASE_URL
|
||||
: STAGING_API_BASE_URL
|
||||
type ComfyApiEnvironment = {
|
||||
isCloudDistribution: boolean
|
||||
isDev: boolean
|
||||
devServerComfyUIUrl?: string
|
||||
useProdConfig: boolean
|
||||
}
|
||||
|
||||
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
? PROD_PLATFORM_BASE_URL
|
||||
: STAGING_PLATFORM_BASE_URL
|
||||
const localOriginPattern =
|
||||
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/
|
||||
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_API_BASE_URL
|
||||
function buildTimeApiBaseUrl(useProdConfig: boolean): string {
|
||||
return useProdConfig ? PROD_API_BASE_URL : STAGING_API_BASE_URL
|
||||
}
|
||||
|
||||
function buildTimePlatformBaseUrl(useProdConfig: boolean): string {
|
||||
return useProdConfig ? PROD_PLATFORM_BASE_URL : STAGING_PLATFORM_BASE_URL
|
||||
}
|
||||
|
||||
function isLocalDevServer(url?: string): boolean {
|
||||
return url ? localOriginPattern.test(url) : false
|
||||
}
|
||||
|
||||
export function getComfyApiBaseUrlForEnvironment({
|
||||
isCloudDistribution,
|
||||
isDev,
|
||||
devServerComfyUIUrl,
|
||||
useProdConfig
|
||||
}: ComfyApiEnvironment): string {
|
||||
const buildTimeApiBaseUrlValue = buildTimeApiBaseUrl(useProdConfig)
|
||||
if (!isCloudDistribution) {
|
||||
return buildTimeApiBaseUrlValue
|
||||
}
|
||||
if (isDev && isLocalDevServer(devServerComfyUIUrl)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_api_base_url',
|
||||
BUILD_TIME_API_BASE_URL
|
||||
buildTimeApiBaseUrlValue
|
||||
)
|
||||
}
|
||||
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
return getComfyApiBaseUrlForEnvironment({
|
||||
isCloudDistribution: isCloud,
|
||||
isDev: import.meta.env.DEV,
|
||||
devServerComfyUIUrl: __DEV_SERVER_COMFYUI_URL__,
|
||||
useProdConfig: __USE_PROD_CONFIG__
|
||||
})
|
||||
}
|
||||
|
||||
export function getComfyPlatformBaseUrl(): string {
|
||||
const buildTimePlatformBaseUrlValue =
|
||||
buildTimePlatformBaseUrl(__USE_PROD_CONFIG__)
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_PLATFORM_BASE_URL
|
||||
return buildTimePlatformBaseUrlValue
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
BUILD_TIME_PLATFORM_BASE_URL
|
||||
buildTimePlatformBaseUrlValue
|
||||
)
|
||||
}
|
||||
|
||||
37
src/config/firebase.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getFirebaseConfigForEnvironment,
|
||||
getFirebaseAuthEmulatorUrl
|
||||
} from '@/config/firebase'
|
||||
|
||||
describe('firebase config', () => {
|
||||
it('uses the explicit local project id when the auth emulator is enabled', () => {
|
||||
const config = getFirebaseConfigForEnvironment({
|
||||
isCloudBuild: false,
|
||||
useProdConfig: false,
|
||||
authEmulatorHost: '127.0.0.1:9099',
|
||||
localProjectId: 'demo-cloud'
|
||||
})
|
||||
|
||||
expect(config.projectId).toBe('demo-cloud')
|
||||
expect(config.authDomain).toBe('demo-cloud.firebaseapp.com')
|
||||
})
|
||||
|
||||
it('fails fast when the auth emulator is enabled without a local project id', () => {
|
||||
expect(() =>
|
||||
getFirebaseConfigForEnvironment({
|
||||
isCloudBuild: false,
|
||||
useProdConfig: false,
|
||||
authEmulatorHost: '127.0.0.1:9099'
|
||||
})
|
||||
).toThrow('VITE_FIREBASE_PROJECT_ID is required')
|
||||
})
|
||||
|
||||
it('does not connect to the emulator without the explicit host flag', () => {
|
||||
expect(getFirebaseAuthEmulatorUrl(undefined)).toBeNull()
|
||||
expect(getFirebaseAuthEmulatorUrl('127.0.0.1:9099')).toBe(
|
||||
'http://127.0.0.1:9099'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,54 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
measurementId: 'G-3ZBD3MBTG4'
|
||||
}
|
||||
|
||||
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
|
||||
type FirebaseEnvironment = {
|
||||
isCloudBuild: boolean
|
||||
useProdConfig: boolean
|
||||
authEmulatorHost?: string
|
||||
localProjectId?: string
|
||||
}
|
||||
|
||||
function buildLocalEmulatorConfig(
|
||||
buildTimeConfig: FirebaseOptions,
|
||||
localProjectId: string | undefined
|
||||
): FirebaseOptions {
|
||||
if (!localProjectId) {
|
||||
throw new Error(
|
||||
'VITE_FIREBASE_PROJECT_ID is required when VITE_FIREBASE_AUTH_EMULATOR_HOST is set'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...buildTimeConfig,
|
||||
projectId: localProjectId,
|
||||
authDomain: `${localProjectId}.firebaseapp.com`
|
||||
}
|
||||
}
|
||||
|
||||
export function getFirebaseAuthEmulatorUrl(
|
||||
host: string | undefined
|
||||
): string | null {
|
||||
return host ? `http://${host}` : null
|
||||
}
|
||||
|
||||
export function getFirebaseConfigForEnvironment({
|
||||
isCloudBuild,
|
||||
useProdConfig,
|
||||
authEmulatorHost,
|
||||
localProjectId
|
||||
}: FirebaseEnvironment): FirebaseOptions {
|
||||
const buildTimeConfig = useProdConfig ? PROD_CONFIG : DEV_CONFIG
|
||||
if (authEmulatorHost) {
|
||||
return buildLocalEmulatorConfig(buildTimeConfig, localProjectId)
|
||||
}
|
||||
|
||||
if (!isCloudBuild) {
|
||||
return buildTimeConfig
|
||||
}
|
||||
|
||||
const runtimeConfig = remoteConfig.value.firebase_config
|
||||
return runtimeConfig ?? buildTimeConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Firebase configuration for the current environment.
|
||||
@@ -33,10 +80,10 @@ const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
*/
|
||||
export function getFirebaseConfig(): FirebaseOptions {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
const runtimeConfig = remoteConfig.value.firebase_config
|
||||
return runtimeConfig ?? BUILD_TIME_CONFIG
|
||||
return getFirebaseConfigForEnvironment({
|
||||
isCloudBuild: isCloud,
|
||||
useProdConfig: __USE_PROD_CONFIG__,
|
||||
authEmulatorHost: import.meta.env.VITE_FIREBASE_AUTH_EMULATOR_HOST,
|
||||
localProjectId: import.meta.env.VITE_FIREBASE_PROJECT_ID
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Category: BC.01 — Node lifecycle: creation
|
||||
// DB cross-ref: S2.N1, S2.N8
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 nodeCreated(node) + beforeRegisterNodeDef → v2 defineNodeExtension({ nodeCreated(handle) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.01 migration — node lifecycle: creation', () => {
|
||||
describe('nodeCreated parity (S2.N1)', () => {
|
||||
it.todo(
|
||||
'v1 nodeCreated and v2 nodeCreated are both invoked the same number of times when N nodes are created'
|
||||
)
|
||||
it.todo(
|
||||
'side-effects applied to the node in v1 nodeCreated(node) are reproducible via NodeHandle methods in v2'
|
||||
)
|
||||
it.todo(
|
||||
'v2 nodeCreated fires in the same relative order as v1 for extensions registered in the same order'
|
||||
)
|
||||
})
|
||||
|
||||
describe('beforeRegisterNodeDef → type-scoped defineNodeExtension (S2.N8)', () => {
|
||||
it.todo(
|
||||
'prototype mutation applied in v1 beforeRegisterNodeDef produces the same per-instance behavior as v2 type-scoped nodeCreated'
|
||||
)
|
||||
it.todo(
|
||||
'v2 type-scoped extension does not affect node types that were excluded, matching v1 type-guard behavior'
|
||||
)
|
||||
})
|
||||
|
||||
describe('VueNode mount timing invariant', () => {
|
||||
it.todo(
|
||||
'both v1 and v2 nodeCreated fire before VueNode mounts — extensions relying on this ordering do not need changes'
|
||||
)
|
||||
it.todo(
|
||||
'extensions that deferred DOM work to a callback in v1 can use onNodeMounted in v2 for the same guarantee'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
// Category: BC.01 — Node lifecycle: creation
|
||||
// DB cross-ref: S2.N1, S2.N8
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
// Surface: S2.N1 = nodeCreated hook, S2.N8 = beforeRegisterNodeDef
|
||||
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: app.registerExtension({ nodeCreated(node) { ... } })
|
||||
// Note: nodeCreated fires BEFORE the VueNode Vue component mounts; extensions needing
|
||||
// VueNode-backed state must defer (see BC.37).
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.01 v1 contract — node lifecycle: creation', () => {
|
||||
describe('S2.N1 — nodeCreated hook', () => {
|
||||
it.todo(
|
||||
'nodeCreated is called once per node instance immediately after the node is constructed'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated receives the LGraphNode instance as its first argument'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated fires before the node is added to the graph (graph.nodes does not yet contain the node)'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated fires before the VueNode Vue component is mounted (vm.$el is null at call time)'
|
||||
)
|
||||
it.todo(
|
||||
'properties set on node inside nodeCreated are accessible in subsequent lifecycle hooks'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N8 — beforeRegisterNodeDef hook', () => {
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef is called once per node type before the type is registered in the node registry'
|
||||
)
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef receives the node constructor and the raw node definition object'
|
||||
)
|
||||
it.todo(
|
||||
'prototype mutations made in beforeRegisterNodeDef affect all subsequently created instances of that type'
|
||||
)
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef is NOT called again on graph reload if the type is already registered'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
// Category: BC.01 — Node lifecycle: creation
|
||||
// DB cross-ref: S2.N1, S2.N8
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ nodeCreated(handle) { ... } })
|
||||
// Note: v2 nodeCreated receives a NodeHandle, not a raw LGraphNode. VueNode mount
|
||||
// timing guarantee is unchanged — defer to onNodeMounted for Vue-backed state.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.01 v2 contract — node lifecycle: creation', () => {
|
||||
describe('nodeCreated(handle) — per-instance setup', () => {
|
||||
it.todo(
|
||||
'nodeCreated is called once per node instance and receives a NodeHandle wrapping the created node'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.id is stable and matches the underlying LGraphNode id at call time'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.type returns the registered node type string'
|
||||
)
|
||||
it.todo(
|
||||
'state stored via NodeHandle.setState() inside nodeCreated is retrievable in subsequent hooks for the same instance'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated fires before VueNode mounts; accessing NodeHandle.vueRef inside nodeCreated returns null'
|
||||
)
|
||||
})
|
||||
|
||||
describe('type-level registration (replacement for S2.N8)', () => {
|
||||
it.todo(
|
||||
'defineNodeExtension({ types: [\"MyNode\"] }) scopes nodeCreated to only instances of the listed types'
|
||||
)
|
||||
it.todo(
|
||||
'omitting types: causes nodeCreated to fire for every node type (global registration)'
|
||||
)
|
||||
it.todo(
|
||||
'type-scoped registration does not receive nodeCreated calls for unregistered node types'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
// Category: BC.02 — Node lifecycle: teardown
|
||||
// DB cross-ref: S2.N4
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.onRemoved assignment → v2 defineNodeExtension({ onRemoved(handle) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.02 migration — node lifecycle: teardown', () => {
|
||||
describe('invocation parity (S2.N4)', () => {
|
||||
it.todo(
|
||||
'v1 onRemoved and v2 onRemoved are both called the same number of times for the same sequence of node removals'
|
||||
)
|
||||
it.todo(
|
||||
'v2 onRemoved fires at the same point in the removal lifecycle as v1 (after node is detached from graph)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('resource cleanup equivalence', () => {
|
||||
it.todo(
|
||||
'intervals cleared in v1 onRemoved are equally suppressible via NodeHandle.onDispose() in v2 without manual tracking'
|
||||
)
|
||||
it.todo(
|
||||
'DOM elements removed manually in v1 onRemoved are automatically removed by v2 auto-disposal when registered via addDOMWidget()'
|
||||
)
|
||||
it.todo(
|
||||
'observer.disconnect() patterns in v1 can be replaced by NodeHandle.onDispose(() => observer.disconnect()) in v2'
|
||||
)
|
||||
})
|
||||
|
||||
describe('graph clear coverage', () => {
|
||||
it.todo(
|
||||
'both v1 and v2 teardown hooks are invoked for all nodes when graph.clear() is called'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
// Category: BC.02 — Node lifecycle: teardown
|
||||
// DB cross-ref: S2.N4
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
// Surface: S2.N4 = node.onRemoved
|
||||
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: node.onRemoved = function() { /* cleanup DOM, intervals, observers */ }
|
||||
//
|
||||
// I-TF.3.C3 — proof-of-concept harness wiring.
|
||||
// Phase A harness limitation: MiniGraph.remove() deletes the entity from the World
|
||||
// but does NOT automatically call onRemoved (that requires Phase B eval sandbox +
|
||||
// LiteGraph prototype wiring). The wired tests below call onRemoved explicitly after
|
||||
// graph.remove() to prove the harness mechanics and assertion patterns work.
|
||||
// The TODO stubs below them track what needs Phase B to become real assertions.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
countEvidenceExcerpts,
|
||||
createHarnessWorld,
|
||||
createMiniComfyApp,
|
||||
loadEvidenceSnippet
|
||||
} from '../harness'
|
||||
|
||||
// ── Proof-of-concept wired tests (I-TF.3.C3) ────────────────────────────────
|
||||
// These pass today. They prove: (a) the harness can model the v1 teardown
|
||||
// pattern, (b) removal is reflected in the World, (c) the cleanup callback
|
||||
// fires when the extension calls it, (d) evidence excerpts load for S2.N4.
|
||||
|
||||
describe('BC.02 v1 contract — node lifecycle: teardown [harness POC]', () => {
|
||||
describe('S2.N4 — onRemoved harness mechanics', () => {
|
||||
it('cleanup callback fires when extension calls it after graph.remove()', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
|
||||
// v1 pattern: extension patches onRemoved on the node during nodeCreated.
|
||||
// We model this as a plain function stored on a node-shaped object.
|
||||
const cleanupFn = vi.fn()
|
||||
const node = {
|
||||
type: 'LTXVideo',
|
||||
entityId: app.graph.add({ type: 'LTXVideo' }),
|
||||
onRemoved: cleanupFn
|
||||
}
|
||||
|
||||
expect(world.findNode(node.entityId)).toBeDefined()
|
||||
|
||||
// Simulate the LiteGraph removal sequence (Phase A: explicit call).
|
||||
app.graph.remove(node.entityId)
|
||||
node.onRemoved()
|
||||
|
||||
expect(world.findNode(node.entityId)).toBeUndefined()
|
||||
expect(cleanupFn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleanup callback does not fire if remove is never called', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
const cleanupFn = vi.fn()
|
||||
const entityId = app.graph.add({ type: 'KSampler' })
|
||||
|
||||
// Node exists; no removal; callback should not have been invoked.
|
||||
void entityId
|
||||
expect(cleanupFn).not.toHaveBeenCalled()
|
||||
expect(world.allNodes()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('multiple nodes — each removal triggers only its own callback', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
|
||||
const cbA = vi.fn()
|
||||
const cbB = vi.fn()
|
||||
const idA = app.graph.add({ type: 'NodeA' })
|
||||
const idB = app.graph.add({ type: 'NodeB' })
|
||||
|
||||
// Remove only A.
|
||||
app.graph.remove(idA)
|
||||
cbA() // simulate LiteGraph calling onRemoved on the removed node only
|
||||
|
||||
expect(cbA).toHaveBeenCalledOnce()
|
||||
expect(cbB).not.toHaveBeenCalled()
|
||||
expect(world.findNode(idA)).toBeUndefined()
|
||||
expect(world.findNode(idB)).toBeDefined()
|
||||
})
|
||||
|
||||
it('graph.clear() removes all nodes from the World', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
|
||||
app.graph.add({ type: 'NodeA' })
|
||||
app.graph.add({ type: 'NodeB' })
|
||||
app.graph.add({ type: 'NodeC' })
|
||||
expect(world.allNodes()).toHaveLength(3)
|
||||
|
||||
world.clear()
|
||||
expect(world.allNodes()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('S2.N4 — evidence excerpt (loadEvidenceSnippet)', () => {
|
||||
it('S2.N4 has at least one evidence excerpt in the snapshot', () => {
|
||||
expect(countEvidenceExcerpts('S2.N4')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('S2.N4 excerpt contains onRemoved fingerprint', () => {
|
||||
const snippet = loadEvidenceSnippet('S2.N4', 0)
|
||||
expect(snippet.length).toBeGreaterThan(0)
|
||||
expect(snippet).toMatch(/onRemoved/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Phase B stubs — need eval sandbox + LiteGraph prototype wiring ───────────
|
||||
|
||||
describe('BC.02 v1 contract — node lifecycle: teardown [Phase B]', () => {
|
||||
describe('S2.N4 — node.onRemoved', () => {
|
||||
it.todo(
|
||||
'onRemoved is called exactly once when a node is removed from the graph via graph.remove(node)'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved is called when a node is deleted via the canvas context-menu delete action'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved is called for every node when the graph is cleared (graph.clear())'
|
||||
)
|
||||
it.todo(
|
||||
'DOM widgets appended by the extension are accessible for cleanup inside onRemoved (not yet garbage-collected)'
|
||||
)
|
||||
it.todo(
|
||||
'setInterval / requestAnimationFrame handles stored on the node instance can be cancelled inside onRemoved'
|
||||
)
|
||||
it.todo(
|
||||
'MutationObserver and ResizeObserver instances stored on the node can be disconnected inside onRemoved'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
// Category: BC.02 — Node lifecycle: teardown
|
||||
// DB cross-ref: S2.N4
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ onRemoved(handle) { ... } })
|
||||
// Note: v2 onRemoved runs inside the NodeHandle scope; extension-owned resources
|
||||
// registered via handle APIs are auto-disposed before onRemoved fires.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.02 v2 contract — node lifecycle: teardown', () => {
|
||||
describe('onRemoved(handle) — cleanup hook', () => {
|
||||
it.todo(
|
||||
'onRemoved is called exactly once per node instance when the node is removed from the graph'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved receives the same NodeHandle that was passed to nodeCreated for the same instance'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.getState() is still readable inside onRemoved (state not yet cleared)'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved is called for every node when the graph is cleared, in no guaranteed order'
|
||||
)
|
||||
})
|
||||
|
||||
describe('auto-disposal of handle-registered resources', () => {
|
||||
it.todo(
|
||||
'DOM widgets registered via NodeHandle.addDOMWidget() are removed from the DOM before onRemoved fires'
|
||||
)
|
||||
it.todo(
|
||||
'cleanup functions registered via NodeHandle.onDispose() are invoked before onRemoved fires'
|
||||
)
|
||||
it.todo(
|
||||
'extension can still perform additional teardown in onRemoved after auto-disposal completes'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
// Category: BC.03 — Node lifecycle: hydration from saved workflows
|
||||
// DB cross-ref: S1.H1, S2.N7
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.onConfigure / beforeRegisterNodeDef → v2 defineNodeExtension({ onConfigure(handle, data) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.03 migration — node lifecycle: hydration from saved workflows', () => {
|
||||
describe('onConfigure parity (S2.N7)', () => {
|
||||
it.todo(
|
||||
'v1 node.onConfigure and v2 onConfigure are both called exactly once per node during workflow load'
|
||||
)
|
||||
it.todo(
|
||||
'the serialized data object received in v2 onConfigure contains the same fields as in v1'
|
||||
)
|
||||
it.todo(
|
||||
'custom property restoration logic written for v1 onConfigure is portable to v2 with only handle substitution'
|
||||
)
|
||||
})
|
||||
|
||||
describe('beforeRegisterNodeDef hydration guard → type-scoped extension (S1.H1)', () => {
|
||||
it.todo(
|
||||
'prototype-level onConfigure injected via v1 beforeRegisterNodeDef produces the same hydration result as a v2 type-scoped onConfigure'
|
||||
)
|
||||
it.todo(
|
||||
'v2 type-scoped onConfigure does not fire for node types not listed in types:, matching v1 guard behavior'
|
||||
)
|
||||
})
|
||||
|
||||
describe('fresh-creation exclusion invariant', () => {
|
||||
it.todo(
|
||||
'neither v1 nor v2 onConfigure fires when a node is created fresh (not from a saved workflow)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
// Category: BC.03 — Node lifecycle: hydration from saved workflows
|
||||
// DB cross-ref: S1.H1, S2.N7
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// Surface: S1.H1 = beforeRegisterNodeDef (used for hydration guards), S2.N7 = node.onConfigure
|
||||
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: S1.H1 = beforeRegisterNodeDef guard; S2.N7 = node.onConfigure = function(data) { ... }
|
||||
// Note: loadedGraphNode hook exists in LiteGraph but is effectively unused in ComfyUI —
|
||||
// onConfigure is the de-facto hydration surface.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.03 v1 contract — node lifecycle: hydration from saved workflows', () => {
|
||||
describe('S2.N7 — node.onConfigure', () => {
|
||||
it.todo(
|
||||
'onConfigure is called when a saved workflow is loaded and the node is rehydrated from serialized data'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure receives the raw serialized node object (data) as its first argument'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure is NOT called on freshly created nodes (only on deserialization)'
|
||||
)
|
||||
it.todo(
|
||||
'widget values written to data inside a prior session are accessible via data.widgets_values in onConfigure'
|
||||
)
|
||||
it.todo(
|
||||
'extensions can restore custom properties stored in data.properties inside onConfigure'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S1.H1 — beforeRegisterNodeDef hydration guard', () => {
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef can inject a custom onConfigure override on the node prototype before any instance is created'
|
||||
)
|
||||
it.todo(
|
||||
'prototype-level onConfigure injected in beforeRegisterNodeDef is invoked for all instances during workflow load'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
// Category: BC.03 — Node lifecycle: hydration from saved workflows
|
||||
// DB cross-ref: S1.H1, S2.N7
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ onConfigure(handle, data) { ... } })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.03 v2 contract — node lifecycle: hydration from saved workflows', () => {
|
||||
describe('onConfigure(handle, data) — workflow hydration hook', () => {
|
||||
it.todo(
|
||||
'onConfigure is called when a node is rehydrated from a saved workflow and NOT on fresh node creation'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure receives the NodeHandle as first argument and the raw serialized node object as second argument'
|
||||
)
|
||||
it.todo(
|
||||
'data passed to onConfigure contains widgets_values from the saved workflow'
|
||||
)
|
||||
it.todo(
|
||||
'data passed to onConfigure contains properties from the saved workflow'
|
||||
)
|
||||
it.todo(
|
||||
'state written to NodeHandle inside onConfigure is readable in all subsequent hook calls for that instance'
|
||||
)
|
||||
})
|
||||
|
||||
describe('ordering and idempotency guarantees', () => {
|
||||
it.todo(
|
||||
'onConfigure fires after nodeCreated for the same instance during workflow load'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure is not called a second time if the same node receives a re-configure (idempotent load)'
|
||||
)
|
||||
})
|
||||
})
|
||||