Compare commits
11 Commits
glary/redr
...
glary/work
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0258e70d | ||
|
|
208c18edec | ||
|
|
28d11c7161 | ||
|
|
5adeb0c9fe | ||
|
|
145fd3909f | ||
|
|
958b7eb486 | ||
|
|
ea2e0fee9d | ||
|
|
1f795c7fbe | ||
|
|
0eeddb6669 | ||
|
|
5ee6e627b9 | ||
|
|
72ac6773a1 |
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Cloud Nodes Pull
|
||||
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
|
||||
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
12
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,12 +106,17 @@ jobs:
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
@@ -125,8 +130,7 @@ jobs:
|
||||
needs: merge
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
11
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -58,7 +58,6 @@ jobs:
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -152,20 +151,10 @@ jobs:
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
|
||||
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles and
|
||||
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
|
||||
# Vercel website production deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshots:
|
||||
refresh-snapshot:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,39 +31,28 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
uses: ./.github/actions/cloud-nodes-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
body: |
|
||||
Automated refresh of remote-data snapshots used by the website
|
||||
build:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
|
||||
board API
|
||||
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
|
||||
`/api/object_info`
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
|
||||
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
|
||||
without the respective API keys continue to use the committed
|
||||
snapshot (with a warning annotation in CI).
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
1
.gitignore
vendored
@@ -16,6 +16,7 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
*.tsbuildinfo
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -90,6 +90,17 @@ const config: StorybookConfig = {
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/linkFixer',
|
||||
replacement:
|
||||
process.cwd() + '/packages/workflow-validation/src/linkRepair.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workflow/validation/schemas/workflowSchema',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/workflow-validation/src/workflowSchema.ts'
|
||||
},
|
||||
{
|
||||
find: '@',
|
||||
replacement: process.cwd() + '/src'
|
||||
|
||||
@@ -119,44 +119,6 @@ snapshots can't be accidentally committed.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
### Production strictness
|
||||
|
||||
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
|
||||
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
|
||||
prevents the production deploy from silently shipping an out-of-date snapshot
|
||||
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
|
||||
and local builds continue to use the committed snapshot with a warning
|
||||
annotation.
|
||||
|
||||
### Required GitHub Actions / Vercel secrets
|
||||
|
||||
| Name | Where | Purpose |
|
||||
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
|
||||
|
||||
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
|
||||
`apps/website/src/data/cloud-nodes.snapshot.json` via
|
||||
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
|
||||
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
|
||||
`deploy-production` job hard-fails before `vercel build --prod` if the secret
|
||||
is missing.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
To update the committed snapshot manually (e.g. after onboarding new packs
|
||||
to Comfy Cloud):
|
||||
|
||||
```bash
|
||||
WEBSITE_CLOUD_API_KEY=… \
|
||||
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
git commit apps/website/src/data/cloud-nodes.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
|
||||
can't be accidentally committed. Otherwise the `Release: Website` GitHub
|
||||
Actions workflow runs the same step on every manual dispatch and opens a PR
|
||||
with the refreshed snapshot.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Pricing page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud pricing teaser @smoke', () => {
|
||||
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud')
|
||||
await expect(
|
||||
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 99 KiB |
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
const contactColumn = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,11 +91,6 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.cloudStatus', locale),
|
||||
href: externalLinks.cloudStatus,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ref } from 'vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
import PricingTierCard from './PricingTierCard.vue'
|
||||
import { SHOW_FREE_TIER } from '../../config/features'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
@@ -38,23 +37,21 @@ interface PricingPlan {
|
||||
isEnterprise?: boolean
|
||||
}
|
||||
|
||||
const freePlan: PricingPlan = {
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
}
|
||||
|
||||
const plans: PricingPlan[] = [
|
||||
...(SHOW_FREE_TIER ? [freePlan] : []),
|
||||
{
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
@@ -64,9 +61,7 @@ const plans: PricingPlan[] = [
|
||||
estimateKey: 'pricing.plan.standard.estimate',
|
||||
ctaKey: 'pricing.plan.standard.cta',
|
||||
ctaHref: subscribeUrl('standard'),
|
||||
featureIntroKey: SHOW_FREE_TIER
|
||||
? 'pricing.plan.standard.featureIntro'
|
||||
: undefined,
|
||||
featureIntroKey: 'pricing.plan.standard.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
@@ -155,14 +150,9 @@ const activePlanIndex = ref(0)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
|
||||
<!-- Desktop: 4-column grid / Mobile: single card -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
|
||||
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
|
||||
)
|
||||
"
|
||||
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
|
||||
>
|
||||
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
|
||||
<!-- Label + badge -->
|
||||
@@ -233,18 +223,10 @@ const activePlanIndex = ref(0)
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
|
||||
{{
|
||||
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' '
|
||||
}}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { SHOW_FREE_TIER } from '../../../config/features'
|
||||
import { getRoutes } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
@@ -26,10 +25,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const SHOW_FREE_TIER = false
|
||||
@@ -32,7 +32,6 @@ export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
|
||||
@@ -1773,7 +1773,6 @@ const translations = {
|
||||
'footer.support': { en: 'Support', 'zh-CN': '支持' },
|
||||
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
|
||||
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
|
||||
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.location': {
|
||||
en: 'San Francisco, USA',
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
const fetchCloudNodesMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<FetchOutcome>>()
|
||||
)
|
||||
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes', () => ({
|
||||
fetchCloudNodesForBuild: fetchCloudNodesMock
|
||||
}))
|
||||
|
||||
vi.mock('./cloudNodes.ci', () => ({
|
||||
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
|
||||
}))
|
||||
|
||||
import { loadPacksForBuild } from './cloudNodes.build'
|
||||
|
||||
const SNAPSHOT: NodesSnapshot = {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'snapshot-pack',
|
||||
displayName: 'Snapshot Pack',
|
||||
nodes: [
|
||||
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('loadPacksForBuild', () => {
|
||||
const savedVercelEnv = process.env.VERCEL_ENV
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCloudNodesMock.mockReset()
|
||||
reportCloudNodesOutcomeMock.mockReset()
|
||||
delete process.env.VERCEL_ENV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (savedVercelEnv === undefined) {
|
||||
delete process.env.VERCEL_ENV
|
||||
return
|
||||
}
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
})
|
||||
|
||||
it('returns packs when fetch is fresh', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'fresh',
|
||||
snapshot: SNAPSHOT,
|
||||
droppedCount: 0,
|
||||
droppedNodes: []
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale outside production', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
|
||||
process.env.VERCEL_ENV = 'preview'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when outcome is stale on Vercel production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/stale data in a production build/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/missing WEBSITE_CLOUD_API_KEY/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when outcome is failed regardless of environment', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'network error: ECONNREFUSED'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/Cloud nodes fetch failed and no snapshot is available/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
|
||||
})
|
||||
|
||||
it('still reports outcome before throwing on stale-in-production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow()
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,6 @@ import type { Pack } from '../data/cloudNodes'
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
|
||||
const REFRESH_HINT =
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
|
||||
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
|
||||
|
||||
function isProductionBuild(): boolean {
|
||||
return process.env.VERCEL_ENV === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of packs to render at build time.
|
||||
*
|
||||
@@ -19,10 +11,6 @@ function isProductionBuild(): boolean {
|
||||
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
|
||||
* `inflight` promise, so repeated calls in the same build process share a
|
||||
* single network round-trip and the same outcome.
|
||||
*
|
||||
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
|
||||
* to prevent silently shipping out-of-date snapshot data. Preview and
|
||||
* local builds continue to use the committed snapshot.
|
||||
*/
|
||||
export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
@@ -30,14 +18,8 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale' && isProductionBuild()) {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
|
||||
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"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": [
|
||||
"147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png[output]",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"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": ["ComfyUI_00001_.png [output]", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [430, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["clip.mp4 [output]", "image"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadAudio",
|
||||
"pos": [810, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "AUDIO",
|
||||
"type": "AUDIO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadAudio"
|
||||
},
|
||||
"widgets_values": ["sound.wav [output]", null, ""]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "KSampler",
|
||||
"pos": [230, 110],
|
||||
"size": [270, 317.5666809082031],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [-80.55032348632812, 375.2260443115233],
|
||||
"size": [270, 80.23332977294922],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveFloat"
|
||||
},
|
||||
"widgets_values": [0]
|
||||
}
|
||||
],
|
||||
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8264462809917354,
|
||||
"offset": [1335.8909766107738, 692.7345403667316]
|
||||
},
|
||||
"frontendVersion": "1.45.4"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -285,12 +285,10 @@ export class ComfyPage {
|
||||
|
||||
async setup({
|
||||
clearStorage = true,
|
||||
mockReleases = true,
|
||||
url
|
||||
mockReleases = true
|
||||
}: {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
url?: string
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
if (mockReleases) {
|
||||
@@ -322,7 +320,7 @@ export class ComfyPage {
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto({ url })
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
@@ -349,8 +347,8 @@ export class ComfyPage {
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto({ url }: { url?: string } = {}) {
|
||||
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
|
||||
async goto() {
|
||||
await this.page.goto(this.url)
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
|
||||
@@ -246,18 +246,4 @@ export class VueNodeHelpers {
|
||||
position: { x: box.width / 2, y: box.height * 0.75 }
|
||||
})
|
||||
}
|
||||
async isSlotConnected(slot: Locator) {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) return false
|
||||
|
||||
return await this.page.evaluate((key) => {
|
||||
const [nodeId, type, slotId] = key.split('-')
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@ export class ContextMenu {
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
protected readonly anyMenu: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
this.anyMenu = this.primeVueMenu
|
||||
.or(this.litegraphMenu)
|
||||
.or(this.litegraphContextMenu)
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
@@ -40,7 +36,16 @@ export class ContextMenu {
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
return await this.anyMenu.isVisible()
|
||||
const primeVueVisible = await this.primeVueMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphContextVisible = await this.litegraphContextMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible || litegraphContextVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -53,7 +58,7 @@ export class ContextMenu {
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect(this.anyMenu).toBeVisible()
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class SubgraphEditor {
|
||||
public readonly root: Locator
|
||||
public readonly promotionItems: Locator
|
||||
|
||||
constructor(protected readonly comfyPage: ComfyPage) {
|
||||
this.root = this.comfyPage.menu.propertiesPanel.root
|
||||
this.promotionItems = this.root.getByTestId(
|
||||
TestIds.subgraphEditor.widgetItem
|
||||
)
|
||||
}
|
||||
|
||||
async open(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
}
|
||||
|
||||
resolveItem(options: {
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
widgetName: string
|
||||
}): Locator {
|
||||
const nodeItems =
|
||||
options.nodeId !== undefined
|
||||
? this.comfyPage.page.locator(`[data-nodeid="${options.nodeId}"]`)
|
||||
: options.nodeName !== undefined
|
||||
? this.promotionItems.filter({
|
||||
has: this.comfyPage.page
|
||||
.getByTestId(TestIds.subgraphEditor.nodeName)
|
||||
.filter({ hasText: options.nodeName })
|
||||
})
|
||||
: this.promotionItems
|
||||
|
||||
return nodeItems.filter({
|
||||
has: this.comfyPage.page
|
||||
.getByTestId(TestIds.subgraphEditor.widgetLabel)
|
||||
.filter({ hasText: options.widgetName })
|
||||
})
|
||||
}
|
||||
|
||||
getToggleButton(item: Locator) {
|
||||
return item.getByTestId(TestIds.subgraphEditor.widgetToggle)
|
||||
}
|
||||
|
||||
async togglePromotionOnItem(item: Locator, toState?: boolean) {
|
||||
const toggleIcon = item.getByTestId(TestIds.subgraphEditor.iconEye)
|
||||
if (toState !== undefined) {
|
||||
const expectedIcon = `icon-[lucide--eye${toState ? '-off' : ''}]`
|
||||
await expect(toggleIcon).toContainClass(expectedIcon)
|
||||
}
|
||||
await toggleIcon.click()
|
||||
}
|
||||
|
||||
async togglePromotion(
|
||||
subgraphNode: Locator,
|
||||
options: {
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
widgetName: string
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.open(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
}
|
||||
async dragItem(fromIndex: number, toIndex: number) {
|
||||
await dragByIndex(this.promotionItems, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,6 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -31,33 +26,3 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -2,7 +2,34 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
|
||||
@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -85,20 +20,43 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
}
|
||||
}
|
||||
|
||||
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,12 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class SubgraphHelper {
|
||||
public readonly editor: SubgraphEditor
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.editor = new SubgraphEditor(comfyPage)
|
||||
}
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
@@ -332,23 +327,6 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async promoteWidget(nodeLocator: Locator, widgetName: string): Promise<void> {
|
||||
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
|
||||
await this.comfyPage.contextMenu
|
||||
.openFor(widget)
|
||||
.then((m) => m.clickMenuItemExact(`Promote Widget: ${widgetName}`))
|
||||
}
|
||||
|
||||
async unpromoteWidget(
|
||||
nodeLocator: Locator,
|
||||
widgetName: string
|
||||
): Promise<void> {
|
||||
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
|
||||
await this.comfyPage.contextMenu
|
||||
.openFor(widget)
|
||||
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
|
||||
}
|
||||
|
||||
async isInSubgraph(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
|
||||
function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
}
|
||||
|
||||
function cloneIndex(
|
||||
index: readonly WorkflowTemplates[] | null
|
||||
): WorkflowTemplates[] | null {
|
||||
return index ? index.map((m) => structuredClone(m)) : null
|
||||
}
|
||||
|
||||
function addTemplates(
|
||||
config: TemplateConfig,
|
||||
templates: TemplateInfo[]
|
||||
): TemplateConfig {
|
||||
return { ...config, templates: [...config.templates, ...templates] }
|
||||
}
|
||||
|
||||
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: TemplateConfig = emptyConfig()
|
||||
) {
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
configure(...operators: TemplateOperator[]): void {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
const thumbnailHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
return cloneTemplates(this.templates)
|
||||
}
|
||||
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
page: Page,
|
||||
...operators: TemplateOperator[]
|
||||
): TemplateHelper {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new TemplateHelper(page, config)
|
||||
}
|
||||
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type {
|
||||
JobStatus,
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const terminalJobStatuses = [
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
] as const satisfies readonly JobStatus[]
|
||||
const activeJobStatuses = [
|
||||
'in_progress',
|
||||
'pending'
|
||||
] as const satisfies readonly JobStatus[]
|
||||
const defaultJobsListLimit = 200
|
||||
const defaultScenarioHistoryLimit = 64
|
||||
const defaultJobsListOffset = 0
|
||||
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
|
||||
interface JobsListRoute {
|
||||
statuses: readonly JobStatus[]
|
||||
jobs: readonly RawJobListItem[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
|
||||
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
|
||||
const requestedStatuses = new Set(
|
||||
url.searchParams.get('status')?.split(',') ?? []
|
||||
)
|
||||
|
||||
return (
|
||||
requestedStatuses.size === statuses.length &&
|
||||
statuses.every((status) => requestedStatuses.has(status))
|
||||
)
|
||||
}
|
||||
|
||||
function searchParamNumber(url: URL, name: string, fallback: number): number {
|
||||
const value = url.searchParams.get(name)
|
||||
return value === null ? fallback : Number(value)
|
||||
}
|
||||
|
||||
function hasJobsListPageParams(
|
||||
url: URL,
|
||||
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
|
||||
): boolean {
|
||||
return (
|
||||
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
|
||||
(limit ?? defaultJobsListLimit) &&
|
||||
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
|
||||
(offset ?? defaultJobsListOffset)
|
||||
)
|
||||
}
|
||||
|
||||
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
|
||||
return (
|
||||
url.pathname.endsWith('/api/jobs') &&
|
||||
hasExactStatuses(url, route.statuses) &&
|
||||
hasJobsListPageParams(url, route)
|
||||
)
|
||||
}
|
||||
|
||||
function createJobsListResponse({
|
||||
jobs,
|
||||
limit = defaultJobsListLimit,
|
||||
offset = defaultJobsListOffset
|
||||
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
|
||||
const pageJobs = jobs.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
jobs: pageJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: jobs.length,
|
||||
has_more: offset + pageJobs.length < jobs.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouteMockJob({
|
||||
id,
|
||||
...overrides
|
||||
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
|
||||
return {
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: defaultRouteMockJobTimestamp,
|
||||
execution_start_time: defaultRouteMockJobTimestamp,
|
||||
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
jobs: readonly RawJobListItem[],
|
||||
limit = defaultJobsListLimit
|
||||
): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: terminalJobStatuses,
|
||||
jobs,
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
|
||||
await this.mockJobsList({
|
||||
statuses: activeJobStatuses,
|
||||
jobs
|
||||
})
|
||||
}
|
||||
|
||||
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
|
||||
if (history) {
|
||||
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
|
||||
}
|
||||
if (queue) {
|
||||
await this.mockJobsQueue(queue)
|
||||
}
|
||||
}
|
||||
|
||||
async mockJobsList(route: JobsListRoute): Promise<void> {
|
||||
const response = createJobsListResponse(route)
|
||||
|
||||
await this.page.route(
|
||||
(url) => isJobsListRequest(url, route),
|
||||
async (requestRoute) => {
|
||||
if (requestRoute.request().method().toUpperCase() !== 'GET') {
|
||||
await requestRoute.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
await requestRoute.fulfill({ json: response })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const jobsRouteFixture = base.extend<{
|
||||
jobsRoutes: JobsRouteMocker
|
||||
}>({
|
||||
jobsRoutes: async ({ page }, use) => {
|
||||
await use(new JobsRouteMocker(page))
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,6 @@ export const TestIds = {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
@@ -104,16 +103,14 @@ export const TestIds = {
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
subgraphEditor: {
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
iconEye: 'icon-eye',
|
||||
iconLink: 'icon-link',
|
||||
nodeName: 'subgraph-widget-node-name',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button',
|
||||
widgetItem: 'subgraph-widget-item',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
widgetToggle: 'subgraph-widget-toggle',
|
||||
widgetLabel: 'subgraph-widget-label',
|
||||
widgetToggle: 'subgraph-widget-toggle'
|
||||
iconLink: 'icon-link',
|
||||
iconEye: 'icon-eye',
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button'
|
||||
},
|
||||
node: {
|
||||
titleInput: 'node-title-input',
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
|
||||
export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
@@ -122,19 +122,3 @@ export async function saveAndReopenInAppMode(
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
export async function saveCloseAndReopenInBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
export async function dragByIndex(
|
||||
items: Locator,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
52
browser_tests/fixtures/utils/jobFixtures.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobRecords(
|
||||
listItems: readonly JobEntry[]
|
||||
): MockJobRecord[] {
|
||||
return listItems.map(createMockJobRecord)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export class VueNodeFixture {
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
public readonly content: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -28,7 +27,6 @@ export class VueNodeFixture {
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
this.content = locator.locator('.lg-node-content')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -41,10 +39,6 @@ export class VueNodeFixture {
|
||||
await this.titleEditor.setTitle(value)
|
||||
}
|
||||
|
||||
async select() {
|
||||
await this.header.click()
|
||||
}
|
||||
|
||||
async toggleCollapse(): Promise<void> {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
@@ -66,15 +60,4 @@ export class VueNodeFixture {
|
||||
boundingBox(): ReturnType<Locator['boundingBox']> {
|
||||
return this.locator.boundingBox()
|
||||
}
|
||||
|
||||
getSlot(nameOrLocator: string | Locator) {
|
||||
const slotLocators = this.root
|
||||
.getByTestId('node-widget')
|
||||
.or(this.root.locator('.lg-slot'))
|
||||
const filteredLocator =
|
||||
typeof nameOrLocator === 'string'
|
||||
? slotLocators.filter({ hasText: nameOrLocator })
|
||||
: slotLocators.filter({ has: nameOrLocator })
|
||||
return filteredLocator.getByTestId('slot-dot').locator('..')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
saveCloseAndReopenInBuilder,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
test.describe(
|
||||
'App builder input persistence after reload',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('persists selected inputs after save and reopen without visibility errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
const workflowName = `${Date.now()} input-persistence`
|
||||
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
for (const widget of WIDGETS) {
|
||||
await expect(
|
||||
appMode.select.getInputItemSubtitle(widget)
|
||||
).not.toContainText('Widget not visible')
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -5,18 +5,26 @@ import {
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
saveCloseAndReopenInBuilder,
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
|
||||
async function saveCloseAndReopenAsApp(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).toBeHidden()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
|
||||
@@ -247,14 +247,6 @@ test.describe('Cloud notification dialog', () => {
|
||||
await dialog.back.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
|
||||
const dialog = new CloudNotification(comfyPage.page)
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
|
||||
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -74,90 +74,6 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Image without workflow', () => {
|
||||
test('places LoadImage at the drop cursor when graph_mouse is stale', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.graph_mouse[0] = -9999
|
||||
window.app!.canvas.graph_mouse[1] = -9999
|
||||
})
|
||||
|
||||
const dropPosition = { x: 480, y: 320 }
|
||||
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
|
||||
const { nodePos, expectedPos } = await comfyPage.page.evaluate(
|
||||
(drop) => {
|
||||
const canvas = window.app!.canvas
|
||||
const expected = canvas.convertEventToCanvasOffset(
|
||||
new MouseEvent('drop', {
|
||||
clientX: drop.x,
|
||||
clientY: drop.y
|
||||
})
|
||||
) as [number, number]
|
||||
const node = window.app!.graph.nodes[0]
|
||||
return {
|
||||
nodePos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
expectedPos: expected
|
||||
}
|
||||
},
|
||||
dropPosition
|
||||
)
|
||||
|
||||
expect(nodePos[0]).toBeCloseTo(expectedPos[0], 0)
|
||||
expect(nodePos[1]).toBeCloseTo(expectedPos[1], 0)
|
||||
expect(nodePos[0]).not.toBe(-9999)
|
||||
expect(nodePos[1]).not.toBe(-9999)
|
||||
})
|
||||
|
||||
test('places LoadImage above existing nodes (zIndex)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
expect(initialNodeIds.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
|
||||
dropPosition: { x: 540, y: 380 },
|
||||
waitForUpload: true
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.vueNodes.getNodeCount())
|
||||
.toBe(initialNodeIds.length + 1)
|
||||
|
||||
const newNodeIds = await comfyPage.vueNodes.getNodeIds()
|
||||
const addedNodeId = newNodeIds.find(
|
||||
(id) => !initialNodeIds.includes(id)
|
||||
)
|
||||
expect(addedNodeId).toBeDefined()
|
||||
|
||||
const newNodeZ = await comfyPage.vueNodes
|
||||
.getNodeLocator(addedNodeId!)
|
||||
.evaluate((el) => Number((el as HTMLElement).style.zIndex))
|
||||
|
||||
const existingZIndexes = await comfyPage.vueNodes.nodes.evaluateAll(
|
||||
(els, id) =>
|
||||
els
|
||||
.filter((el) => el.getAttribute('data-node-id') !== id)
|
||||
.map((el) => Number((el as HTMLElement).style.zIndex)),
|
||||
addedNodeId!
|
||||
)
|
||||
|
||||
expect(newNodeZ).toBeGreaterThan(Math.max(0, ...existingZIndexes))
|
||||
})
|
||||
})
|
||||
|
||||
test('Load workflow from URL dropped onto Vue node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -25,21 +25,6 @@ const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
// NaN-variant fixtures embed only an API-format prompt containing bare
|
||||
// `NaN`/`Infinity` tokens (Python's `json.dumps` default). The loader must
|
||||
// tolerate Python generated JSON for these to import successfully.
|
||||
const NAN_FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_nan_metadata.json', parser: 'json' },
|
||||
{ fileName: 'with_nan_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_nan_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_nan_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_nan_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_nan_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_nan_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_nan_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_nan_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
@@ -73,42 +58,5 @@ test.describe(
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const { fileName, parser } of NAN_FIXTURES) {
|
||||
test(`loads Python JSON prompt with NaN/Infinity from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the NaN-laden prompt'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
await test.step('NaN-coerced widget values are 0', async () => {
|
||||
const [ksampler] =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
for (const widgetName of ['cfg', 'denoise']) {
|
||||
const widget = await ksampler.getWidgetByName(widgetName)
|
||||
expect(
|
||||
await widget.getValue(),
|
||||
`${widgetName} should be 0 after NaN coercion to null`
|
||||
).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -549,7 +549,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
expect(uploadCount, 'should upload exactly once').toBe(1)
|
||||
})
|
||||
|
||||
test('Empty canvas uploads a transparent placeholder on serialization', async ({
|
||||
test('Empty canvas does not upload on serialization', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let uploadCount = 0
|
||||
@@ -566,10 +566,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
|
||||
expect(
|
||||
uploadCount,
|
||||
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
|
||||
).toBe(1)
|
||||
expect(uploadCount, 'empty canvas should not upload').toBe(0)
|
||||
})
|
||||
|
||||
test('Upload failure shows error toast', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
|
||||
const cloudOutputAsset: Asset = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
|
||||
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
|
||||
const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
markUploadedCloudAssetAvailable: () => void
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
}
|
||||
cloudUploadAssetStateByPage.set(page, state)
|
||||
|
||||
const assetsRouteHandler = async (route: Route) => {
|
||||
const allAssets = [
|
||||
cloudDefaultGraphInputAsset,
|
||||
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
|
||||
]
|
||||
const includeTags =
|
||||
new URL(route.request().url()).searchParams
|
||||
.get('include_tags')
|
||||
?.split(',')
|
||||
.filter(Boolean) ?? []
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
asset.tags?.some((tag) => includeTags.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
},
|
||||
markUploadedCloudAssetAvailable: async ({ page }, use) => {
|
||||
await use(() => {
|
||||
const state = cloudUploadAssetStateByPage.get(page)
|
||||
if (state) state.isUploadedAssetAvailable = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function enableErrorsTab(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
let releaseUpload!: () => void
|
||||
let resolveUploadStarted!: () => void
|
||||
const uploadStarted = new Promise<void>((resolve) => {
|
||||
resolveUploadStarted = resolve
|
||||
})
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseUpload = resolve
|
||||
})
|
||||
|
||||
const uploadRouteHandler = async (route: Route) => {
|
||||
resolveUploadStarted()
|
||||
await release
|
||||
await route.continue()
|
||||
}
|
||||
|
||||
await comfyPage.page.route('**/upload/image', uploadRouteHandler)
|
||||
|
||||
return {
|
||||
waitForUploadStarted: () => uploadStarted,
|
||||
finishUpload: async () => {
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/upload/image') && response.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
releaseUpload()
|
||||
try {
|
||||
await uploadResponse
|
||||
} finally {
|
||||
await comfyPage.page.unroute('**/upload/image', uploadRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLoadVideoUploading(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.some(
|
||||
(node) => node.type === 'LoadVideo' && node.isUploading
|
||||
)
|
||||
),
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let sawErrorOverlay = false
|
||||
const startedAt = Date.now()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
sawErrorOverlay =
|
||||
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
|
||||
return (
|
||||
!sawErrorOverlay &&
|
||||
Date.now() - startedAt >= missingMediaUploadObservationMs
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
|
||||
intervals: [missingMediaUploadPollMs]
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function outputHistoryJobs(): RawJobListItem[] {
|
||||
return [
|
||||
createRouteMockJob({
|
||||
id: 'history-output-image',
|
||||
preview_output: {
|
||||
filename: 'ComfyUI_00001_.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'history-output-video',
|
||||
preview_output: {
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'video'
|
||||
}
|
||||
}),
|
||||
createRouteMockJob({
|
||||
id: 'history-output-audio',
|
||||
preview_output: {
|
||||
filename: 'sound.wav',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'audio'
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
ossTest.describe(
|
||||
'Errors tab - OSS missing media runtime sources',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
ossTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
})
|
||||
|
||||
ossTest(
|
||||
'resolves annotated output media from job history',
|
||||
async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_output_annotations'
|
||||
)
|
||||
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
ossTest(
|
||||
'does not surface missing media while dropped video upload is in progress',
|
||||
async ({ comfyFiles, comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const delayedUpload = await delayNextUpload(comfyPage)
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
})
|
||||
await delayedUpload.waitForUploadStarted()
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: plainVideoFileName,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest.describe(
|
||||
'Errors tab - Cloud missing media runtime sources',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
})
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves compact annotated output media from output assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_annotation'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'output')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
cloudUploadRaceTest.describe(
|
||||
'Errors tab - Cloud missing media upload race',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsTab(comfyPage)
|
||||
})
|
||||
|
||||
cloudUploadRaceTest(
|
||||
'does not surface missing media while dropped video upload is in progress',
|
||||
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const delayedUpload = await delayNextUpload(comfyPage)
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
})
|
||||
await delayedUpload.waitForUploadStarted()
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: plainVideoFileName,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
|
||||
markUploadedCloudAssetAvailable()
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1,54 +1,56 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: mockJobTimestamp - 60_000,
|
||||
execution_start_time: mockJobTimestamp - 60_000,
|
||||
execution_end_time: mockJobTimestamp - 50_000,
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: mockJobTimestamp - 120_000,
|
||||
execution_start_time: mockJobTimestamp - 120_000,
|
||||
execution_end_time: mockJobTimestamp - 115_000,
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: mockJobTimestamp - 30_000,
|
||||
execution_start_time: mockJobTimestamp - 30_000,
|
||||
execution_end_time: mockJobTimestamp - 28_000,
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createRouteMockJob({
|
||||
createMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: mockJobTimestamp - 180_000,
|
||||
execution_start_time: mockJobTimestamp - 180_000,
|
||||
execution_end_time: mockJobTimestamp - 178_000,
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
|
||||
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -54,44 +54,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens the right-side info tab in new menu mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click()
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(panel).toContainText('KSampler')
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('info button is hidden when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
|
||||
@@ -74,16 +74,14 @@ test.describe(
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'Node Info'
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
})
|
||||
await expect(nodeInfoButton).toBeHidden()
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
|
||||
@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -607,218 +607,3 @@ test.describe(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: false
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
})
|
||||
|
||||
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'linked widgets are first by default'
|
||||
).toHaveText('steps')
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'Swap widget order'
|
||||
).toContainText('cfg')
|
||||
|
||||
// FIXME: solve actual bug and remove the not
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'Linked widget is first on node'
|
||||
).not.toHaveText('cfg')
|
||||
})
|
||||
|
||||
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -26,37 +26,53 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await templateApi.mockThumbnails()
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -70,38 +86,45 @@ test.describe(
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -121,29 +144,36 @@ test.describe(
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -170,21 +200,28 @@ test.describe(
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -197,32 +234,39 @@ test.describe(
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage,
|
||||
templateApi
|
||||
comfyPage
|
||||
}) => {
|
||||
templateApi.configure(
|
||||
withTemplates([
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/workflows/published/test-share-id',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-share-id',
|
||||
workflow_id: 'wf-1',
|
||||
name: 'Shared Workflow',
|
||||
listed: true,
|
||||
publish_time: new Date().toISOString(),
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
},
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?share=test-share-id'
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
|
||||
@@ -1133,108 +1133,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
|
||||
test('should keep widget-input link aligned after persisted-workflow reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'vueNodes/ksampler-denoise-widget-link'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
await comfyPage.workflow.waitForDraftPersisted()
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
|
||||
const ksampler = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
|
||||
if (!node) return null
|
||||
const findIndex = (name: string) =>
|
||||
node.inputs.findIndex(
|
||||
(input) => input.name === name || input.widget?.name === name
|
||||
)
|
||||
return {
|
||||
id: node.id,
|
||||
denoiseIndex: findIndex('denoise'),
|
||||
schedulerIndex: findIndex('scheduler')
|
||||
}
|
||||
})
|
||||
if (!ksampler) {
|
||||
throw new Error('KSampler should be present in fixture')
|
||||
}
|
||||
expect(
|
||||
ksampler.denoiseIndex,
|
||||
'denoise input slot not found'
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
ksampler.schedulerIndex,
|
||||
'scheduler input slot not found'
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const denoiseSlot = slotLocator(
|
||||
comfyPage.page,
|
||||
ksampler.id,
|
||||
ksampler.denoiseIndex,
|
||||
true
|
||||
)
|
||||
const schedulerSlot = slotLocator(
|
||||
comfyPage.page,
|
||||
ksampler.id,
|
||||
ksampler.schedulerIndex,
|
||||
true
|
||||
)
|
||||
await expectVisibleAll(denoiseSlot, schedulerSlot)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
getInputLinkDetails(comfyPage.page, ksampler.id, ksampler.denoiseIndex)
|
||||
)
|
||||
.toMatchObject({
|
||||
targetId: ksampler.id,
|
||||
targetSlot: ksampler.denoiseIndex
|
||||
})
|
||||
|
||||
// If the regression returns, getInputPos stays stale relative to the
|
||||
// grown slot DOM and the endpoint drifts toward scheduler. Re-read
|
||||
// positions each retry so layout settle doesn't cause flakes.
|
||||
await expect(async () => {
|
||||
const linkEnd = await comfyPage.page.evaluate(
|
||||
([nodeId, targetSlotIndex]) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
const slotPos = node.getInputPos(targetSlotIndex)
|
||||
const [cx, cy] = window.app!.canvas.ds.convertOffsetToCanvas([
|
||||
slotPos[0],
|
||||
slotPos[1]
|
||||
])
|
||||
const rect = window.app!.canvas.canvas.getBoundingClientRect()
|
||||
return { x: cx + rect.left, y: cy + rect.top }
|
||||
},
|
||||
[ksampler.id, ksampler.denoiseIndex] as const
|
||||
)
|
||||
expect(linkEnd, 'link endpoint should resolve').not.toBeNull()
|
||||
|
||||
const denoiseCenter = await getCenter(denoiseSlot)
|
||||
const schedulerCenter = await getCenter(schedulerSlot)
|
||||
const distToDenoise = Math.hypot(
|
||||
linkEnd!.x - denoiseCenter.x,
|
||||
linkEnd!.y - denoiseCenter.y
|
||||
)
|
||||
const rowGap = Math.hypot(
|
||||
denoiseCenter.x - schedulerCenter.x,
|
||||
denoiseCenter.y - schedulerCenter.y
|
||||
)
|
||||
|
||||
// Bound at rowGap / 4 - half the inter-slot midpoint, so any drift
|
||||
// toward scheduler fails well before reaching it.
|
||||
expect(
|
||||
distToDenoise,
|
||||
`Link endpoint (${linkEnd!.x.toFixed(1)}, ${linkEnd!.y.toFixed(1)}) is ` +
|
||||
`${distToDenoise.toFixed(1)}px from denoise — should be within ` +
|
||||
`${(rowGap / 4).toFixed(1)}px (quarter of inter-slot gap ${rowGap.toFixed(1)}px)`
|
||||
).toBeLessThan(rowGap / 4)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,24 +75,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should open node info in the right side panel via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
@@ -4,7 +4,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -14,9 +13,7 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -25,61 +22,6 @@ const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
const LOAD_IMAGE_INPUT_NAME = 'image'
|
||||
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
||||
|
||||
function buildLoadImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'LoadImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
||||
details: '',
|
||||
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function surfaceLoadImageMissingInputError(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[loadImageId]: buildLoadImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
}
|
||||
|
||||
async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const loadImageId = String(loadImageNode.id)
|
||||
|
||||
return {
|
||||
loadImageId,
|
||||
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
||||
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -249,74 +191,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user drops an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('drop an image onto the Load Image node', async () => {
|
||||
const dropPosition =
|
||||
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
||||
if (!dropPosition) {
|
||||
throw new Error('Load Image node center must be available for drop')
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user pastes an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('paste an image while Load Image is selected', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
||||
)
|
||||
.toBe('LoadImage')
|
||||
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
// File clipboard contents cannot be seeded reliably in Playwright;
|
||||
// use the direct document paste mode to exercise usePaste.
|
||||
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
||||
mode: 'direct'
|
||||
})
|
||||
await uploadResponse
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
@@ -26,10 +26,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.8",
|
||||
"version": "1.45.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -64,6 +64,7 @@
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@comfyorg/workflow-validation": "workspace:*",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
@@ -205,6 +206,7 @@
|
||||
"vue-component-type-helpers": "catalog:",
|
||||
"vue-eslint-parser": "catalog:",
|
||||
"vue-tsc": "catalog:",
|
||||
"yaml": "catalog:",
|
||||
"zip-dir": "^2.0.0",
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 306 B |
33
packages/ingest-types/src/types.gen.ts
generated
@@ -523,19 +523,6 @@ export type ImportPublishedAssetsRequest = {
|
||||
* IDs of published assets (inputs and models) to import.
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* Optional. Share ID of the published workflow these assets belong to.
|
||||
* When provided (non-null, non-empty): all published_asset_ids must
|
||||
* belong to this share's workflow version; returns
|
||||
* 400/CodeInvalidAssets if the share is not found or any asset does
|
||||
* not belong to it.
|
||||
* When omitted, null, or empty string: no share-scoped validation is
|
||||
* performed and the assets are validated only against global rules
|
||||
* (legacy behaviour, preserved for clients that have not yet adopted
|
||||
* share_id).
|
||||
*
|
||||
*/
|
||||
share_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2321,15 +2308,9 @@ export type Asset = {
|
||||
*/
|
||||
preview_id?: string | null
|
||||
/**
|
||||
* Deprecated: use job_id instead. ID of the prompt that created this asset, if available
|
||||
*
|
||||
* @deprecated
|
||||
* ID of the job/prompt that created this asset, if available
|
||||
*/
|
||||
prompt_id?: string | null
|
||||
/**
|
||||
* ID of the job that created this asset, if available
|
||||
*/
|
||||
job_id?: string | null
|
||||
/**
|
||||
* Timestamp when the asset was created
|
||||
*/
|
||||
@@ -3169,15 +3150,9 @@ export type AssetWritable = {
|
||||
*/
|
||||
preview_id?: string | null
|
||||
/**
|
||||
* Deprecated: use job_id instead. ID of the prompt that created this asset, if available
|
||||
*
|
||||
* @deprecated
|
||||
* ID of the job/prompt that created this asset, if available
|
||||
*/
|
||||
prompt_id?: string | null
|
||||
/**
|
||||
* ID of the job that created this asset, if available
|
||||
*/
|
||||
job_id?: string | null
|
||||
/**
|
||||
* Timestamp when the asset was created
|
||||
*/
|
||||
@@ -4940,6 +4915,10 @@ export type ImportPublishedAssetsErrors = {
|
||||
* Unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
|
||||
5
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()),
|
||||
share_id: z.string().nullish()
|
||||
published_asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1372,7 +1371,6 @@ export const zAsset = z.object({
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
@@ -1814,7 +1812,6 @@ export const zAssetWritable = z.object({
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isCivitaiUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
@@ -424,19 +423,6 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCivitaiUrl', () => {
|
||||
it.for([
|
||||
{ url: 'https://civitai.com/models/123', expected: true },
|
||||
{ url: 'https://civitai.red/models/123', expected: true },
|
||||
{ url: 'https://sub.civitai.com/models/123', expected: true },
|
||||
{ url: 'https://sub.civitai.red/models/123', expected: true },
|
||||
{ url: 'https://example.com/model', expected: false },
|
||||
{ url: 'not-a-url', expected: false }
|
||||
])('$url → $expected', ({ url, expected }) => {
|
||||
expect(isCivitaiUrl(url)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCivitaiModelUrl', () => {
|
||||
it('recognizes civitai.red model URLs', () => {
|
||||
expect(
|
||||
|
||||
@@ -379,22 +379,6 @@ export const generateUUID = (): string => {
|
||||
})
|
||||
}
|
||||
|
||||
const isCivitaiHost = (hostname: string): boolean =>
|
||||
hostname === 'civitai.com' ||
|
||||
hostname.endsWith('.civitai.com') ||
|
||||
hostname === 'civitai.red' ||
|
||||
hostname.endsWith('.civitai.red')
|
||||
|
||||
/**
|
||||
* Checks if a URL belongs to any Civitai domain (civitai.com or civitai.red).
|
||||
* Use this for source-name detection; use `isCivitaiModelUrl` when the URL
|
||||
* must also match a specific model API path format.
|
||||
*/
|
||||
export const isCivitaiUrl = (url: string): boolean => {
|
||||
if (!isValidUrl(url)) return false
|
||||
return isCivitaiHost(new URL(url).hostname.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a Civitai model URL
|
||||
* @example
|
||||
@@ -407,9 +391,17 @@ export const isCivitaiModelUrl = (url: string): boolean => {
|
||||
if (!isValidUrl(url)) return false
|
||||
|
||||
const urlObj = new URL(url)
|
||||
if (!isCivitaiHost(urlObj.hostname.toLowerCase())) return false
|
||||
|
||||
const hostname = urlObj.hostname.toLowerCase()
|
||||
const isCivitaiHost =
|
||||
hostname === 'civitai.com' ||
|
||||
hostname.endsWith('.civitai.com') ||
|
||||
hostname === 'civitai.red' ||
|
||||
hostname.endsWith('.civitai.red')
|
||||
if (!isCivitaiHost) {
|
||||
return false
|
||||
}
|
||||
const pathname = urlObj.pathname
|
||||
|
||||
return (
|
||||
/^\/api\/download\/models\/(\d+)$/.test(pathname) ||
|
||||
/^\/api\/v1\/models\/(\d+)$/.test(pathname) ||
|
||||
|
||||
41
packages/workflow-validation/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@comfyorg/workflow-validation",
|
||||
"version": "0.1.0",
|
||||
"description": "Workflow JSON schemas, link topology validator, and link repair for ComfyUI workflows",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "GPL-3.0-only",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./linkRepair": "./src/linkRepair.ts",
|
||||
"./linkTopology": "./src/linkTopology.ts",
|
||||
"./workflowSchema": "./src/workflowSchema.ts",
|
||||
"./serialised": "./src/serialised.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --config vite.config.mts && tsx ../../scripts/prepare-workflow-validation.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:validation"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
packages/workflow-validation/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type {
|
||||
SerialisedGraph,
|
||||
SerialisedLinkArray,
|
||||
SerialisedLinkObject,
|
||||
SerialisedNode,
|
||||
SerialisedNodeInput,
|
||||
SerialisedNodeOutput
|
||||
} from './serialised'
|
||||
|
||||
export {
|
||||
describeTopologyError,
|
||||
toLinkContext,
|
||||
validateLinkTopology
|
||||
} from './linkTopology'
|
||||
export type { LinkContext, TopologyError } from './linkTopology'
|
||||
|
||||
export { LinkRepairAbortedError, repairLinks } from './linkRepair'
|
||||
export type { RepairResult } from './linkRepair'
|
||||
|
||||
export { repairLinks as fixBadLinks } from './linkRepair'
|
||||
|
||||
export {
|
||||
validateComfyWorkflow,
|
||||
zComfyWorkflow,
|
||||
zComfyWorkflow1,
|
||||
zNodeId
|
||||
} from './workflowSchema'
|
||||
export type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyLinkObject,
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON,
|
||||
ModelFile,
|
||||
NodeId,
|
||||
Reroute,
|
||||
WorkflowId,
|
||||
WorkflowJSON04
|
||||
} from './workflowSchema'
|
||||
134
packages/workflow-validation/src/linkRepair.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LinkRepairAbortedError, repairLinks } from './linkRepair'
|
||||
import type {
|
||||
SerialisedGraph,
|
||||
SerialisedLinkArray,
|
||||
SerialisedLinkObject,
|
||||
SerialisedNode,
|
||||
SerialisedNodeInput,
|
||||
SerialisedNodeOutput
|
||||
} from './serialised'
|
||||
|
||||
function input(link: number | null): SerialisedNodeInput {
|
||||
return { name: 'i', type: '*', link }
|
||||
}
|
||||
|
||||
function output(links: number[]): SerialisedNodeOutput {
|
||||
return { name: 'o', type: '*', links }
|
||||
}
|
||||
|
||||
describe('repairLinks abort behaviour', () => {
|
||||
it('LinkRepairAbortedError carries the seedance-style topology context', () => {
|
||||
const link = {
|
||||
linkId: 29,
|
||||
originId: 9,
|
||||
originSlot: 0,
|
||||
targetId: 14,
|
||||
targetSlot: 9
|
||||
}
|
||||
const err = new LinkRepairAbortedError({
|
||||
kind: 'target-slot-out-of-bounds',
|
||||
link,
|
||||
targetSlotCount: 5
|
||||
})
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.topologyError.link).toEqual(link)
|
||||
expect(err.message).toContain('[link=29 src=9:0 tgt=14:9]')
|
||||
})
|
||||
|
||||
it('LinkRepairAbortedError exposes a topologyError discriminated union', () => {
|
||||
const err = new LinkRepairAbortedError({
|
||||
kind: 'target-link-mismatch',
|
||||
link: {
|
||||
linkId: 99,
|
||||
originId: 1,
|
||||
originSlot: 0,
|
||||
targetId: 2,
|
||||
targetSlot: 0
|
||||
},
|
||||
actualLink: 5
|
||||
})
|
||||
expect(err.topologyError.kind).toBe('target-link-mismatch')
|
||||
expect(err.message).toContain('[link=99 src=1:0 tgt=2:0]')
|
||||
expect(err.name).toBe('LinkRepairAbortedError')
|
||||
})
|
||||
})
|
||||
|
||||
describe('repairLinks delete-with-missing-index path', () => {
|
||||
it('does not corrupt the link array when the deleted link disappears mid-iteration', () => {
|
||||
const graph: SerialisedGraph = {
|
||||
nodes: [
|
||||
{ id: 1, outputs: [output([99])] },
|
||||
{ id: 2, inputs: [input(99)] }
|
||||
],
|
||||
links: [
|
||||
[42, 1, 0, 2, 5, '*'],
|
||||
[99, 1, 0, 2, 0, '*']
|
||||
]
|
||||
}
|
||||
|
||||
repairLinks(graph, { fix: true, silent: true })
|
||||
|
||||
const surviving = graph.links.find(
|
||||
(l): l is SerialisedLinkArray =>
|
||||
Array.isArray(l) && (l as SerialisedLinkArray)[0] === 99
|
||||
)
|
||||
expect(surviving).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repairLinks live-graph branch', () => {
|
||||
it('uses graph.getNodeById and treats links as a record when the live-graph hook is present', () => {
|
||||
const node1: SerialisedNode = { id: 1, outputs: [output([])] }
|
||||
const node2: SerialisedNode = { id: 2, inputs: [input(null)] }
|
||||
const linkRecord: Record<number, SerialisedLinkObject> = {
|
||||
42: {
|
||||
id: 42,
|
||||
origin_id: 999,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: '*'
|
||||
}
|
||||
}
|
||||
const liveGraph = {
|
||||
nodes: [node1, node2],
|
||||
links: linkRecord,
|
||||
getNodeById: (id: string | number) =>
|
||||
[node1, node2].find((n) => n.id == id)
|
||||
} as unknown as SerialisedGraph & {
|
||||
getNodeById: (id: string | number) => SerialisedNode | undefined
|
||||
}
|
||||
|
||||
repairLinks(liveGraph, { fix: true, silent: true })
|
||||
|
||||
expect(linkRecord[42]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repairLinks happy-path repair flow', () => {
|
||||
it('patches a missing origin reference and deletes a dangling link, reporting non-zero patched and deleted counts', () => {
|
||||
const graph: SerialisedGraph = {
|
||||
nodes: [
|
||||
{ id: 1, outputs: [output([])] },
|
||||
{ id: 2, inputs: [input(10)] },
|
||||
{ id: 3, outputs: [output([])] }
|
||||
],
|
||||
links: [
|
||||
[10, 1, 0, 2, 0, '*'],
|
||||
[99, 3, 0, 999, 0, '*']
|
||||
]
|
||||
}
|
||||
|
||||
const result = repairLinks(graph, { fix: true, silent: true })
|
||||
|
||||
expect(result.patched).toBeGreaterThan(0)
|
||||
expect(result.deleted).toBeGreaterThan(0)
|
||||
expect(graph.nodes[0]!.outputs![0]!.links).toContain(10)
|
||||
const danglingSurvives = (graph.links as SerialisedLinkArray[]).some(
|
||||
(l) => Array.isArray(l) && l[0] === 99
|
||||
)
|
||||
expect(danglingSurvives).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -24,16 +24,17 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
SerialisedGraph,
|
||||
SerialisedLinkArray,
|
||||
SerialisedLinkObject,
|
||||
SerialisedNode,
|
||||
SerialisedNodeOutput
|
||||
} from './serialised'
|
||||
import { describeTopologyError, toLinkContext } from './linkTopology'
|
||||
import type { LinkContext, TopologyError } from './linkTopology'
|
||||
|
||||
interface BadLinksData<T = ISerialisedGraph | LGraph> {
|
||||
export interface RepairResult<T = SerialisedGraph> {
|
||||
hasBadLinks: boolean
|
||||
fixed: boolean
|
||||
graph: T
|
||||
@@ -41,48 +42,64 @@ interface BadLinksData<T = ISerialisedGraph | LGraph> {
|
||||
deleted: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the repair pass detects a divergence between its in-memory
|
||||
* patched view and the live graph data — typically because the workflow's
|
||||
* topology cannot be reconciled (e.g. links pointing to slots that do not
|
||||
* exist on the target node). The attached `TopologyError` carries the
|
||||
* `[linkId, src, srcSlot, tgt, tgtSlot]` tuple so callers can report the
|
||||
* precise offending link instead of a generic invariant failure.
|
||||
*/
|
||||
export class LinkRepairAbortedError extends Error {
|
||||
public readonly topologyError: TopologyError
|
||||
constructor(topologyError: TopologyError) {
|
||||
super(describeTopologyError(topologyError))
|
||||
this.topologyError = topologyError
|
||||
this.name = 'LinkRepairAbortedError'
|
||||
}
|
||||
}
|
||||
|
||||
enum IoDirection {
|
||||
INPUT,
|
||||
OUTPUT
|
||||
}
|
||||
|
||||
function getNodeById(graph: ISerialisedGraph | LGraph, id: NodeId) {
|
||||
if ((graph as LGraph).getNodeById) {
|
||||
return (graph as LGraph).getNodeById(id)
|
||||
}
|
||||
graph = graph as ISerialisedGraph
|
||||
return graph.nodes.find((node: ISerialisedNode) => node.id == id)!
|
||||
interface LiveGraph extends SerialisedGraph {
|
||||
getNodeById(id: string | number): SerialisedNode | undefined
|
||||
}
|
||||
|
||||
function extendLink(link: SerialisedLLinkArray) {
|
||||
return {
|
||||
link: link,
|
||||
id: link[0],
|
||||
origin_id: link[1],
|
||||
origin_slot: link[2],
|
||||
target_id: link[3],
|
||||
target_slot: link[4],
|
||||
type: link[5]
|
||||
}
|
||||
function isLiveGraph(graph: SerialisedGraph | LiveGraph): graph is LiveGraph {
|
||||
return typeof (graph as LiveGraph).getNodeById === 'function'
|
||||
}
|
||||
|
||||
function getNodeById(
|
||||
graph: SerialisedGraph | LiveGraph,
|
||||
id: string | number
|
||||
): SerialisedNode | undefined {
|
||||
if (isLiveGraph(graph)) return graph.getNodeById(id)
|
||||
return graph.nodes.find((n) => n.id == id)
|
||||
}
|
||||
|
||||
interface RepairOptions {
|
||||
fix?: boolean
|
||||
silent?: boolean
|
||||
logger?: { log: (...args: unknown[]) => void }
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a ISerialisedGraph or live LGraph and inspects the links and nodes to ensure the linking
|
||||
* makes logical sense. Can apply fixes when passed the `fix` argument as true.
|
||||
* Best-effort repair of structurally inconsistent link data on a
|
||||
* serialised or live graph. Pass `{ fix: false }` (default) for a dry
|
||||
* run that only reports whether bad links exist.
|
||||
*
|
||||
* Note that fixes are a best-effort attempt. Seems to get it correct in most cases, but there is a
|
||||
* chance it correct an anomaly that results in placing an incorrect link (say, if there were two
|
||||
* links in the data). Users should take care to not overwrite work until manually checking the
|
||||
* result.
|
||||
* Throws `LinkRepairAbortedError` when the graph diverges from the
|
||||
* patched view in a way the algorithm cannot reconcile (e.g. links
|
||||
* pointing into out-of-bounds slots). The error carries a structured
|
||||
* `TopologyError` describing the offending link.
|
||||
*/
|
||||
export function fixBadLinks(
|
||||
graph: ISerialisedGraph | LGraph,
|
||||
options: {
|
||||
fix?: boolean
|
||||
silent?: boolean
|
||||
logger?: { log: (...args: unknown[]) => void }
|
||||
} = {}
|
||||
): BadLinksData {
|
||||
export function repairLinks(
|
||||
graph: SerialisedGraph,
|
||||
options: RepairOptions = {}
|
||||
): RepairResult {
|
||||
const { fix = false, silent = false, logger: _logger = console } = options
|
||||
const logger = {
|
||||
log: (...args: unknown[]) => {
|
||||
@@ -105,18 +122,15 @@ export function fixBadLinks(
|
||||
} = {}
|
||||
|
||||
const data: {
|
||||
patchedNodes: Array<ISerialisedNode | LGraphNode>
|
||||
patchedNodes: SerialisedNode[]
|
||||
deletedLinks: number[]
|
||||
} = {
|
||||
patchedNodes: [],
|
||||
deletedLinks: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal patch node. We keep track of changes in patchedNodeSlots in case we're in a dry run.
|
||||
*/
|
||||
function patchNodeSlot(
|
||||
node: ISerialisedNode | LGraphNode,
|
||||
node: SerialisedNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number,
|
||||
linkId: number,
|
||||
@@ -126,12 +140,9 @@ export function fixBadLinks(
|
||||
const patchedNode = patchedNodeSlots[node.id]!
|
||||
if (ioDir == IoDirection.INPUT) {
|
||||
patchedNode['inputs'] = patchedNode['inputs'] || {}
|
||||
// We can set to null (delete), so undefined means we haven't set it at all.
|
||||
if (patchedNode['inputs']![slot] !== undefined) {
|
||||
logger.log(
|
||||
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
|
||||
'inputs'
|
||||
]![slot]!} Skipping.`
|
||||
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode['inputs']![slot]!} Skipping.`
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -175,8 +186,7 @@ export function fixBadLinks(
|
||||
if (fix) {
|
||||
node.outputs = node.outputs || []
|
||||
node.outputs[slot] =
|
||||
node.outputs[slot] ||
|
||||
({} satisfies Partial<INodeOutputSlot> as INodeOutputSlot)
|
||||
node.outputs[slot] || ({} as SerialisedNodeOutput)
|
||||
node.outputs[slot]!.links = node.outputs[slot]!.links || []
|
||||
node.outputs[slot]!.links!.push(linkId)
|
||||
}
|
||||
@@ -199,25 +209,48 @@ export function fixBadLinks(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal to check if a node (or patched data) has a linkId.
|
||||
*/
|
||||
function buildLinkContext(
|
||||
node: SerialisedNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number,
|
||||
linkId: number
|
||||
): LinkContext {
|
||||
if (ioDir === IoDirection.INPUT) {
|
||||
return {
|
||||
linkId,
|
||||
originId: '?',
|
||||
originSlot: -1,
|
||||
targetId: node.id,
|
||||
targetSlot: slot
|
||||
}
|
||||
}
|
||||
return {
|
||||
linkId,
|
||||
originId: node.id,
|
||||
originSlot: slot,
|
||||
targetId: '?',
|
||||
targetSlot: -1
|
||||
}
|
||||
}
|
||||
|
||||
function nodeHasLinkId(
|
||||
node: ISerialisedNode | LGraphNode,
|
||||
node: SerialisedNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number,
|
||||
linkId: number
|
||||
) {
|
||||
// Patched data should be canonical. We can double check if fixing too.
|
||||
let has = false
|
||||
if (ioDir === IoDirection.INPUT) {
|
||||
const nodeHasIt = node.inputs?.[slot]?.link === linkId
|
||||
if (patchedNodeSlots[node.id]?.['inputs']) {
|
||||
const patchedHasIt =
|
||||
patchedNodeSlots[node.id]!['inputs']![slot] === linkId
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasIt !== patchedHasIt) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
throw new LinkRepairAbortedError({
|
||||
kind: 'target-link-mismatch',
|
||||
link: buildLinkContext(node, ioDir, slot, linkId),
|
||||
actualLink: node.inputs?.[slot]?.link ?? null
|
||||
})
|
||||
}
|
||||
has = patchedHasIt
|
||||
} else {
|
||||
@@ -228,9 +261,11 @@ export function fixBadLinks(
|
||||
if (patchedNodeSlots[node.id]?.['outputs']?.[slot]?.['changes'][linkId]) {
|
||||
const patchedHasIt =
|
||||
patchedNodeSlots[node.id]!['outputs']![slot]?.links.includes(linkId)
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasIt !== patchedHasIt) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
throw new LinkRepairAbortedError({
|
||||
kind: 'origin-link-not-listed',
|
||||
link: buildLinkContext(node, ioDir, slot, linkId)
|
||||
})
|
||||
}
|
||||
has = !!patchedHasIt
|
||||
} else {
|
||||
@@ -240,24 +275,23 @@ export function fixBadLinks(
|
||||
return has
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal to check if a node (or patched data) has a linkId.
|
||||
*/
|
||||
function nodeHasAnyLink(
|
||||
node: ISerialisedNode | LGraphNode,
|
||||
node: SerialisedNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number
|
||||
) {
|
||||
// Patched data should be canonical. We can double check if fixing too.
|
||||
let hasAny = false
|
||||
if (ioDir === IoDirection.INPUT) {
|
||||
const nodeHasAny = node.inputs?.[slot]?.link != null
|
||||
if (patchedNodeSlots[node.id]?.['inputs']) {
|
||||
const patchedHasAny =
|
||||
patchedNodeSlots[node.id]!['inputs']![slot] != null
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasAny !== patchedHasAny) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
throw new LinkRepairAbortedError({
|
||||
kind: 'target-slot-out-of-bounds',
|
||||
link: buildLinkContext(node, ioDir, slot, -1),
|
||||
targetSlotCount: node.inputs?.length ?? 0
|
||||
})
|
||||
}
|
||||
hasAny = patchedHasAny
|
||||
} else {
|
||||
@@ -268,9 +302,12 @@ export function fixBadLinks(
|
||||
if (patchedNodeSlots[node.id]?.['outputs']?.[slot]?.['changes']) {
|
||||
const patchedHasAny =
|
||||
patchedNodeSlots[node.id]!['outputs']![slot]?.links.length
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasAny !== patchedHasAny) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
throw new LinkRepairAbortedError({
|
||||
kind: 'origin-slot-out-of-bounds',
|
||||
link: buildLinkContext(node, ioDir, slot, -1),
|
||||
originSlotCount: node.outputs?.length ?? 0
|
||||
})
|
||||
}
|
||||
hasAny = !!patchedHasAny
|
||||
} else {
|
||||
@@ -280,52 +317,52 @@ export function fixBadLinks(
|
||||
return hasAny
|
||||
}
|
||||
|
||||
let links: Array<SerialisedLLinkArray | LLink> = []
|
||||
let links: Array<SerialisedLinkArray | SerialisedLinkObject> = []
|
||||
if (!Array.isArray(graph.links)) {
|
||||
links = Object.values(graph.links).reduce((acc, v) => {
|
||||
acc[v.id] = v
|
||||
return acc
|
||||
}, links)
|
||||
links = Object.values(graph.links).reduce(
|
||||
(acc: Array<SerialisedLinkArray | SerialisedLinkObject>, v: unknown) => {
|
||||
const link = v as SerialisedLinkObject
|
||||
acc[link.id] = link
|
||||
return acc
|
||||
},
|
||||
links
|
||||
)
|
||||
} else {
|
||||
links = graph.links
|
||||
links = graph.links.filter(
|
||||
(l): l is SerialisedLinkArray | SerialisedLinkObject => l != null
|
||||
)
|
||||
}
|
||||
|
||||
const linksReverse = [...links]
|
||||
linksReverse.reverse()
|
||||
for (const l of linksReverse) {
|
||||
if (!l) continue
|
||||
const link =
|
||||
(l as LLink).origin_slot != null
|
||||
? (l as LLink)
|
||||
: extendLink(l as SerialisedLLinkArray)
|
||||
|
||||
const originNode = getNodeById(graph, link.origin_id)
|
||||
const ctx = toLinkContext(l)
|
||||
const originNode = getNodeById(graph, ctx.originId)
|
||||
const originHasLink = () =>
|
||||
nodeHasLinkId(originNode!, IoDirection.OUTPUT, link.origin_slot, link.id)
|
||||
const patchOrigin = (op: 'ADD' | 'REMOVE', id = link.id) =>
|
||||
patchNodeSlot(originNode!, IoDirection.OUTPUT, link.origin_slot, id, op)
|
||||
nodeHasLinkId(originNode!, IoDirection.OUTPUT, ctx.originSlot, ctx.linkId)
|
||||
const patchOrigin = (op: 'ADD' | 'REMOVE', id = ctx.linkId) =>
|
||||
patchNodeSlot(originNode!, IoDirection.OUTPUT, ctx.originSlot, id, op)
|
||||
|
||||
const targetNode = getNodeById(graph, link.target_id)
|
||||
const targetNode = getNodeById(graph, ctx.targetId)
|
||||
const targetHasLink = () =>
|
||||
nodeHasLinkId(targetNode!, IoDirection.INPUT, link.target_slot, link.id)
|
||||
nodeHasLinkId(targetNode!, IoDirection.INPUT, ctx.targetSlot, ctx.linkId)
|
||||
const targetHasAnyLink = () =>
|
||||
nodeHasAnyLink(targetNode!, IoDirection.INPUT, link.target_slot)
|
||||
const patchTarget = (op: 'ADD' | 'REMOVE', id = link.id) =>
|
||||
patchNodeSlot(targetNode!, IoDirection.INPUT, link.target_slot, id, op)
|
||||
nodeHasAnyLink(targetNode!, IoDirection.INPUT, ctx.targetSlot)
|
||||
const patchTarget = (op: 'ADD' | 'REMOVE', id = ctx.linkId) =>
|
||||
patchNodeSlot(targetNode!, IoDirection.INPUT, ctx.targetSlot, id, op)
|
||||
|
||||
const originLog = `origin(${link.origin_id}).outputs[${link.origin_slot}].links`
|
||||
const targetLog = `target(${link.target_id}).inputs[${link.target_slot}].link`
|
||||
const originLog = `origin(${ctx.originId}).outputs[${ctx.originSlot}].links`
|
||||
const targetLog = `target(${ctx.targetId}).inputs[${ctx.targetSlot}].link`
|
||||
|
||||
if (!originNode || !targetNode) {
|
||||
if (!originNode && !targetNode) {
|
||||
logger.log(
|
||||
`Link ${link.id} is invalid, ` +
|
||||
`both origin ${link.origin_id} and target ${link.target_id} do not exist`
|
||||
`Link ${ctx.linkId} is invalid, both origin ${ctx.originId} and target ${ctx.targetId} do not exist`
|
||||
)
|
||||
} else if (!originNode) {
|
||||
logger.log(
|
||||
`Link ${link.id} is funky... ` +
|
||||
`origin ${link.origin_id} does not exist, but target ${link.target_id} does.`
|
||||
`Link ${ctx.linkId} is funky... origin ${ctx.originId} does not exist, but target ${ctx.targetId} does.`
|
||||
)
|
||||
if (targetHasLink()) {
|
||||
logger.log(
|
||||
@@ -333,14 +370,13 @@ export function fixBadLinks(
|
||||
)
|
||||
patchTarget('REMOVE', -1)
|
||||
}
|
||||
} else if (!targetNode) {
|
||||
} else {
|
||||
logger.log(
|
||||
`Link ${link.id} is funky... ` +
|
||||
`target ${link.target_id} does not exist, but origin ${link.origin_id} does.`
|
||||
`Link ${ctx.linkId} is funky... target ${ctx.targetId} does not exist, but origin ${ctx.originId} does.`
|
||||
)
|
||||
if (originHasLink()) {
|
||||
logger.log(
|
||||
` > [PATCH] Origin's links' has ${link.id}; will remove the link first.`
|
||||
` > [PATCH] Origin's links' has ${ctx.linkId}; will remove the link first.`
|
||||
)
|
||||
patchOrigin('REMOVE')
|
||||
}
|
||||
@@ -351,33 +387,32 @@ export function fixBadLinks(
|
||||
if (targetHasLink() || originHasLink()) {
|
||||
if (!originHasLink()) {
|
||||
logger.log(
|
||||
`${link.id} is funky... ${originLog} does NOT contain it, but ${targetLog} does.`
|
||||
`${ctx.linkId} is funky... ${originLog} does NOT contain it, but ${targetLog} does.`
|
||||
)
|
||||
|
||||
logger.log(
|
||||
` > [PATCH] Attempt a fix by adding this ${link.id} to ${originLog}.`
|
||||
` > [PATCH] Attempt a fix by adding this ${ctx.linkId} to ${originLog}.`
|
||||
)
|
||||
patchOrigin('ADD')
|
||||
} else if (!targetHasLink()) {
|
||||
logger.log(
|
||||
`${link.id} is funky... ${targetLog} is NOT correct (is ${
|
||||
targetNode.inputs?.[link.target_slot]?.link
|
||||
`${ctx.linkId} is funky... ${targetLog} is NOT correct (is ${
|
||||
targetNode.inputs?.[ctx.targetSlot]?.link
|
||||
}), but ${originLog} contains it`
|
||||
)
|
||||
if (!targetHasAnyLink()) {
|
||||
logger.log(
|
||||
` > [PATCH] ${targetLog} is not defined, will set to ${link.id}.`
|
||||
` > [PATCH] ${targetLog} is not defined, will set to ${ctx.linkId}.`
|
||||
)
|
||||
let patched = patchTarget('ADD')
|
||||
if (!patched) {
|
||||
logger.log(
|
||||
` > [PATCH] Nvm, ${targetLog} already patched. Removing ${link.id} from ${originLog}.`
|
||||
` > [PATCH] Nvm, ${targetLog} already patched. Removing ${ctx.linkId} from ${originLog}.`
|
||||
)
|
||||
patched = patchOrigin('REMOVE')
|
||||
}
|
||||
} else {
|
||||
logger.log(
|
||||
` > [PATCH] ${targetLog} is defined, removing ${link.id} from ${originLog}.`
|
||||
` > [PATCH] ${targetLog} is defined, removing ${ctx.linkId} from ${originLog}.`
|
||||
)
|
||||
patchOrigin('REMOVE')
|
||||
}
|
||||
@@ -385,71 +420,67 @@ export function fixBadLinks(
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've cleaned up the inputs, outputs, run through it looking for dangling links.,
|
||||
for (const l of linksReverse) {
|
||||
if (!l) continue
|
||||
const link =
|
||||
(l as LLink).origin_slot != null
|
||||
? (l as LLink)
|
||||
: extendLink(l as SerialisedLLinkArray)
|
||||
const originNode = getNodeById(graph, link.origin_id)
|
||||
const targetNode = getNodeById(graph, link.target_id)
|
||||
// Now that we've manipulated the linking, check again if they both exist.
|
||||
const ctx = toLinkContext(l)
|
||||
const originNode = getNodeById(graph, ctx.originId)
|
||||
const targetNode = getNodeById(graph, ctx.targetId)
|
||||
if (
|
||||
(!originNode ||
|
||||
!nodeHasLinkId(
|
||||
originNode,
|
||||
IoDirection.OUTPUT,
|
||||
link.origin_slot,
|
||||
link.id
|
||||
ctx.originSlot,
|
||||
ctx.linkId
|
||||
)) &&
|
||||
(!targetNode ||
|
||||
!nodeHasLinkId(
|
||||
targetNode,
|
||||
IoDirection.INPUT,
|
||||
link.target_slot,
|
||||
link.id
|
||||
ctx.targetSlot,
|
||||
ctx.linkId
|
||||
))
|
||||
) {
|
||||
logger.log(
|
||||
`${link.id} is def invalid; BOTH origin node ${link.origin_id} ${
|
||||
!originNode ? 'is removed' : `doesn't have ${link.id}`
|
||||
} and ${link.origin_id} target node ${
|
||||
!targetNode ? 'is removed' : `doesn't have ${link.id}`
|
||||
`${ctx.linkId} is def invalid; BOTH origin node ${ctx.originId} ${
|
||||
!originNode ? 'is removed' : `doesn't have ${ctx.linkId}`
|
||||
} and ${ctx.originId} target node ${
|
||||
!targetNode ? 'is removed' : `doesn't have ${ctx.linkId}`
|
||||
}.`
|
||||
)
|
||||
data.deletedLinks.push(link.id)
|
||||
data.deletedLinks.push(ctx.linkId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If we're fixing, then we've been patching along the way. Now go through and actually delete
|
||||
// the zombie links from `app.graph.links`
|
||||
if (fix) {
|
||||
for (let i = data.deletedLinks.length - 1; i >= 0; i--) {
|
||||
logger.log(`Deleting link #${data.deletedLinks[i]}.`)
|
||||
if ((graph as LGraph).getNodeById) {
|
||||
delete graph.links[data.deletedLinks[i]!]
|
||||
if (isLiveGraph(graph)) {
|
||||
delete (graph.links as Record<number, unknown>)[data.deletedLinks[i]!]
|
||||
} else {
|
||||
graph = graph as ISerialisedGraph
|
||||
// Sometimes we got objects for links if passed after ComfyUI's loadGraphData modifies the
|
||||
// data. We make a copy now, but can handle the bastardized objects just in case.
|
||||
const idx = graph.links.findIndex(
|
||||
const idx = (
|
||||
graph.links as Array<
|
||||
SerialisedLinkArray | SerialisedLinkObject | null
|
||||
>
|
||||
).findIndex(
|
||||
(l) =>
|
||||
l &&
|
||||
(l[0] === data.deletedLinks[i] ||
|
||||
((l as SerialisedLinkArray)[0] === data.deletedLinks[i] ||
|
||||
('id' in l && l.id === data.deletedLinks[i]))
|
||||
)
|
||||
if (idx === -1) {
|
||||
logger.log(`INDEX NOT FOUND for #${data.deletedLinks[i]}`)
|
||||
continue
|
||||
}
|
||||
logger.log(`splicing ${idx} from links`)
|
||||
graph.links.splice(idx, 1)
|
||||
;(graph.links as Array<unknown>).splice(idx, 1)
|
||||
}
|
||||
}
|
||||
// If we're a serialized graph, we can filter out the links because it's just an array.
|
||||
if (!(graph as LGraph).getNodeById) {
|
||||
graph.links = (graph as ISerialisedGraph).links.filter((l) => !!l)
|
||||
if (!isLiveGraph(graph)) {
|
||||
graph.links = (
|
||||
graph.links as Array<SerialisedLinkArray | SerialisedLinkObject | null>
|
||||
).filter((l): l is SerialisedLinkArray | SerialisedLinkObject => !!l)
|
||||
}
|
||||
}
|
||||
if (!data.patchedNodes.length && !data.deletedLinks.length) {
|
||||
@@ -470,9 +501,8 @@ export function fixBadLinks(
|
||||
|
||||
const hasChanges = !!(data.patchedNodes.length || data.deletedLinks.length)
|
||||
let hasBadLinks: boolean = hasChanges
|
||||
// If we're fixing, then let's run it again to see if there are no more bad links.
|
||||
if (fix) {
|
||||
const rerun = fixBadLinks(graph, { fix: false, silent: true })
|
||||
const rerun = repairLinks(graph, { fix: false, silent: true })
|
||||
hasBadLinks = rerun.hasBadLinks
|
||||
}
|
||||
|
||||
164
packages/workflow-validation/src/linkTopology.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { describeTopologyError, validateLinkTopology } from './linkTopology'
|
||||
import type { SerialisedGraph } from './serialised'
|
||||
|
||||
function makeGraph(partial: Partial<SerialisedGraph>): SerialisedGraph {
|
||||
return { nodes: [], links: [], ...partial }
|
||||
}
|
||||
|
||||
describe('validateLinkTopology', () => {
|
||||
it('returns no errors for a valid graph', () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, outputs: [{ name: 'o', type: '*', links: [10] }] },
|
||||
{ id: 2, inputs: [{ name: 'i', type: '*', link: 10 }] }
|
||||
],
|
||||
links: [[10, 1, 0, 2, 0, '*']]
|
||||
})
|
||||
expect(validateLinkTopology(graph)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports target slot out of bounds (seedance regression)', () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 9, outputs: [{ name: 'o', type: 'STRING', links: [29] }] },
|
||||
{
|
||||
id: 14,
|
||||
inputs: [
|
||||
{ name: 'a', type: 'STRING', link: null },
|
||||
{ name: 'b', type: 'STRING', link: null },
|
||||
{ name: 'c', type: 'STRING', link: null },
|
||||
{ name: 'd', type: 'STRING', link: 55 },
|
||||
{ name: 'e', type: 'STRING', link: null }
|
||||
]
|
||||
}
|
||||
],
|
||||
links: [[29, 9, 0, 14, 9, 'STRING']]
|
||||
})
|
||||
const errors = validateLinkTopology(graph)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toMatchObject({
|
||||
kind: 'target-slot-out-of-bounds',
|
||||
link: { linkId: 29, targetId: 14, targetSlot: 9 },
|
||||
targetSlotCount: 5
|
||||
})
|
||||
expect(describeTopologyError(errors[0]!)).toContain(
|
||||
'[link=29 src=9:0 tgt=14:9]'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a missing origin node', () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [{ id: 2, inputs: [{ name: 'i', type: '*', link: 10 }] }],
|
||||
links: [[10, 999, 0, 2, 0, '*']]
|
||||
})
|
||||
const errors = validateLinkTopology(graph)
|
||||
expect(errors[0]?.kind).toBe('missing-origin-node')
|
||||
})
|
||||
|
||||
it('reports a target-link mismatch', () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, outputs: [{ name: 'o', type: '*', links: [10] }] },
|
||||
{ id: 2, inputs: [{ name: 'i', type: '*', link: 999 }] }
|
||||
],
|
||||
links: [[10, 1, 0, 2, 0, '*']]
|
||||
})
|
||||
const errors = validateLinkTopology(graph)
|
||||
expect(errors[0]).toMatchObject({
|
||||
kind: 'target-link-mismatch',
|
||||
actualLink: 999
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts object-form links for valid graphs', () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, outputs: [{ name: 'o', type: '*', links: [10] }] },
|
||||
{ id: 2, inputs: [{ name: 'i', type: '*', link: 10 }] }
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 10,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: '*'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(validateLinkTopology(graph)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports object-form links with out-of-bounds slots', () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, outputs: [{ name: 'o', type: '*', links: [10] }] },
|
||||
{
|
||||
id: 2,
|
||||
inputs: [{ name: 'a', type: '*', link: null }]
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 10,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 5,
|
||||
type: '*'
|
||||
}
|
||||
]
|
||||
})
|
||||
const errors = validateLinkTopology(graph)
|
||||
expect(errors[0]).toMatchObject({
|
||||
kind: 'target-slot-out-of-bounds',
|
||||
link: { linkId: 10, targetId: 2, targetSlot: 5 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeTopologyError', () => {
|
||||
it('formats every error kind with the [linkId, src, srcSlot, tgt, tgtSlot] tuple', () => {
|
||||
const link = {
|
||||
linkId: 7,
|
||||
originId: 3,
|
||||
originSlot: 1,
|
||||
targetId: 4,
|
||||
targetSlot: 2
|
||||
}
|
||||
const tuple = '[link=7 src=3:1 tgt=4:2]'
|
||||
expect(
|
||||
describeTopologyError({ kind: 'missing-origin-node', link })
|
||||
).toContain(tuple)
|
||||
expect(
|
||||
describeTopologyError({ kind: 'missing-target-node', link })
|
||||
).toContain(tuple)
|
||||
expect(
|
||||
describeTopologyError({
|
||||
kind: 'origin-slot-out-of-bounds',
|
||||
link,
|
||||
originSlotCount: 0
|
||||
})
|
||||
).toContain(tuple)
|
||||
expect(
|
||||
describeTopologyError({
|
||||
kind: 'target-slot-out-of-bounds',
|
||||
link,
|
||||
targetSlotCount: 5
|
||||
})
|
||||
).toContain(tuple)
|
||||
expect(
|
||||
describeTopologyError({ kind: 'origin-link-not-listed', link })
|
||||
).toContain(tuple)
|
||||
expect(
|
||||
describeTopologyError({
|
||||
kind: 'target-link-mismatch',
|
||||
link,
|
||||
actualLink: null
|
||||
})
|
||||
).toContain(tuple)
|
||||
})
|
||||
})
|
||||
158
packages/workflow-validation/src/linkTopology.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
SerialisedGraph,
|
||||
SerialisedLinkArray,
|
||||
SerialisedLinkObject,
|
||||
SerialisedNode
|
||||
} from './serialised'
|
||||
|
||||
export interface LinkContext {
|
||||
linkId: number
|
||||
originId: string | number
|
||||
originSlot: number
|
||||
targetId: string | number
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
export type TopologyError =
|
||||
| { kind: 'missing-origin-node'; link: LinkContext }
|
||||
| { kind: 'missing-target-node'; link: LinkContext }
|
||||
| {
|
||||
kind: 'origin-slot-out-of-bounds'
|
||||
link: LinkContext
|
||||
originSlotCount: number
|
||||
}
|
||||
| {
|
||||
kind: 'target-slot-out-of-bounds'
|
||||
link: LinkContext
|
||||
targetSlotCount: number
|
||||
}
|
||||
| { kind: 'origin-link-not-listed'; link: LinkContext }
|
||||
| {
|
||||
kind: 'target-link-mismatch'
|
||||
link: LinkContext
|
||||
actualLink: number | null
|
||||
}
|
||||
|
||||
export function describeTopologyError(error: TopologyError): string {
|
||||
const { linkId, originId, originSlot, targetId, targetSlot } = error.link
|
||||
const tuple = `[link=${linkId} src=${originId}:${originSlot} tgt=${targetId}:${targetSlot}]`
|
||||
switch (error.kind) {
|
||||
case 'missing-origin-node':
|
||||
return `${tuple} origin node ${originId} does not exist in graph`
|
||||
case 'missing-target-node':
|
||||
return `${tuple} target node ${targetId} does not exist in graph`
|
||||
case 'origin-slot-out-of-bounds':
|
||||
return `${tuple} origin slot ${originSlot} is out of bounds; node ${originId} has ${error.originSlotCount} output slot(s)`
|
||||
case 'target-slot-out-of-bounds':
|
||||
return `${tuple} target slot ${targetSlot} is out of bounds; node ${targetId} has ${error.targetSlotCount} input slot(s)`
|
||||
case 'origin-link-not-listed':
|
||||
return `${tuple} link is not listed in node ${originId}.outputs[${originSlot}].links`
|
||||
case 'target-link-mismatch':
|
||||
return `${tuple} node ${targetId}.inputs[${targetSlot}].link is ${error.actualLink}, expected ${linkId}`
|
||||
}
|
||||
}
|
||||
|
||||
function isLinkObject(
|
||||
l: SerialisedLinkArray | SerialisedLinkObject
|
||||
): l is SerialisedLinkObject {
|
||||
return !Array.isArray(l) && typeof l === 'object'
|
||||
}
|
||||
|
||||
export function toLinkContext(
|
||||
l: SerialisedLinkArray | SerialisedLinkObject
|
||||
): LinkContext {
|
||||
if (isLinkObject(l)) {
|
||||
return {
|
||||
linkId: l.id,
|
||||
originId: l.origin_id,
|
||||
originSlot: l.origin_slot,
|
||||
targetId: l.target_id,
|
||||
targetSlot: l.target_slot
|
||||
}
|
||||
}
|
||||
return {
|
||||
linkId: l[0],
|
||||
originId: l[1],
|
||||
originSlot: l[2],
|
||||
targetId: l[3],
|
||||
targetSlot: l[4]
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodeIndex(graph: SerialisedGraph): Map<string, SerialisedNode> {
|
||||
const index = new Map<string, SerialisedNode>()
|
||||
for (const node of graph.nodes) index.set(String(node.id), node)
|
||||
return index
|
||||
}
|
||||
|
||||
function iterateLinks(
|
||||
graph: SerialisedGraph
|
||||
): Array<SerialisedLinkArray | SerialisedLinkObject> {
|
||||
if (Array.isArray(graph.links)) {
|
||||
return graph.links.filter(
|
||||
(l): l is SerialisedLinkArray | SerialisedLinkObject => l != null
|
||||
)
|
||||
}
|
||||
const result: Array<SerialisedLinkArray | SerialisedLinkObject> = []
|
||||
for (const l of Object.values(graph.links)) {
|
||||
if (l) result.push(l as SerialisedLinkObject)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure topology check: every link must reference real nodes, in-bounds
|
||||
* slots, and consistent input/output endpoints. Does not mutate the
|
||||
* graph. Use `repairLinks` (separate module) to attempt auto-fix.
|
||||
*/
|
||||
export function validateLinkTopology(graph: SerialisedGraph): TopologyError[] {
|
||||
const errors: TopologyError[] = []
|
||||
const nodesById = buildNodeIndex(graph)
|
||||
for (const l of iterateLinks(graph)) {
|
||||
const link = toLinkContext(l)
|
||||
const origin = nodesById.get(String(link.originId))
|
||||
const target = nodesById.get(String(link.targetId))
|
||||
|
||||
if (!origin) errors.push({ kind: 'missing-origin-node', link })
|
||||
if (!target) errors.push({ kind: 'missing-target-node', link })
|
||||
if (!origin || !target) continue
|
||||
|
||||
const outputs = origin.outputs ?? []
|
||||
const originSlotOutOfBounds =
|
||||
link.originSlot < 0 || link.originSlot >= outputs.length
|
||||
if (originSlotOutOfBounds) {
|
||||
errors.push({
|
||||
kind: 'origin-slot-out-of-bounds',
|
||||
link,
|
||||
originSlotCount: outputs.length
|
||||
})
|
||||
}
|
||||
const inputs = target.inputs ?? []
|
||||
const targetSlotOutOfBounds =
|
||||
link.targetSlot < 0 || link.targetSlot >= inputs.length
|
||||
if (targetSlotOutOfBounds) {
|
||||
errors.push({
|
||||
kind: 'target-slot-out-of-bounds',
|
||||
link,
|
||||
targetSlotCount: inputs.length
|
||||
})
|
||||
}
|
||||
if (originSlotOutOfBounds || targetSlotOutOfBounds) {
|
||||
continue
|
||||
}
|
||||
|
||||
const originLinks = outputs[link.originSlot]?.links ?? []
|
||||
if (!originLinks.includes(link.linkId)) {
|
||||
errors.push({ kind: 'origin-link-not-listed', link })
|
||||
}
|
||||
const targetLink = inputs[link.targetSlot]?.link ?? null
|
||||
if (targetLink !== link.linkId) {
|
||||
errors.push({
|
||||
kind: 'target-link-mismatch',
|
||||
link,
|
||||
actualLink: targetLink
|
||||
})
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
60
packages/workflow-validation/src/serialised.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Minimal structural types for serialised workflow JSON.
|
||||
*
|
||||
* The validation/repair code in this package operates on plain JSON
|
||||
* (parsed `.json` workflow files) — it does NOT need the runtime
|
||||
* `LGraph`/`LGraphNode` classes from litegraph. Defining the shapes
|
||||
* locally keeps this package free of frontend/litegraph coupling so
|
||||
* it can be consumed by Node.js CI scripts and a future backend
|
||||
* validator.
|
||||
*
|
||||
* These types intentionally mirror the relevant fields used by
|
||||
* `validateLinkTopology` and `repairLinks`. They are a subset of the
|
||||
* `ISerialisedGraph` / `ISerialisedNode` shapes from
|
||||
* `@/lib/litegraph/src/types/serialisation` and stay structurally
|
||||
* compatible with them.
|
||||
*/
|
||||
|
||||
/** Schema version 0.4 link tuple: `[id, originId, originSlot, targetId, targetSlot, type]`. */
|
||||
export type SerialisedLinkArray = [
|
||||
number,
|
||||
string | number,
|
||||
number,
|
||||
string | number,
|
||||
number,
|
||||
string | string[] | number
|
||||
]
|
||||
|
||||
/** Object form of a link (schema version 1, or after live-graph hydration). */
|
||||
export interface SerialisedLinkObject {
|
||||
id: number
|
||||
origin_id: string | number
|
||||
origin_slot: number
|
||||
target_id: string | number
|
||||
target_slot: number
|
||||
type?: string | string[] | number
|
||||
}
|
||||
|
||||
export interface SerialisedNodeInput {
|
||||
name?: string
|
||||
type?: string | string[] | number
|
||||
link?: number | null
|
||||
}
|
||||
|
||||
export interface SerialisedNodeOutput {
|
||||
name?: string
|
||||
type?: string | string[] | number
|
||||
links?: number[] | null
|
||||
}
|
||||
|
||||
export interface SerialisedNode {
|
||||
id: string | number
|
||||
type?: string
|
||||
inputs?: SerialisedNodeInput[]
|
||||
outputs?: SerialisedNodeOutput[]
|
||||
}
|
||||
|
||||
export interface SerialisedGraph {
|
||||
nodes: SerialisedNode[]
|
||||
links: Array<SerialisedLinkArray | SerialisedLinkObject | null>
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import type { SafeParseReturnType } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
import type { RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
|
||||
type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
|
||||
|
||||
const zRendererType = z.enum([
|
||||
'LG',
|
||||
@@ -313,7 +314,16 @@ const zExtra = z
|
||||
.passthrough()
|
||||
|
||||
const zGraphDefinitions = z.object({
|
||||
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => z.array(zSubgraphDefinition)
|
||||
)
|
||||
})
|
||||
|
||||
const zBaseExportableGraph = z.object({
|
||||
9
packages/workflow-validation/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
packages/workflow-validation/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "dist/.tsnode",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.mts"]
|
||||
}
|
||||
26
packages/workflow-validation/vite.config.mts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import dts from 'vite-plugin-dts'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'workflow-validation',
|
||||
formats: ['es'],
|
||||
fileName: 'index'
|
||||
},
|
||||
copyPublicDir: false,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
external: ['zod', 'zod-validation-error']
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
tsconfigPath: 'tsconfig.json',
|
||||
include: ['src/**/*'],
|
||||
exclude: ['src/**/*.test.ts']
|
||||
})
|
||||
]
|
||||
})
|
||||
831
pnpm-lock.yaml
generated
@@ -36,7 +36,7 @@ catalog:
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.3.0
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
@@ -112,7 +112,7 @@ catalog:
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
tailwindcss: ^4.2.0
|
||||
three: ^0.170.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
@@ -137,6 +137,7 @@ catalog:
|
||||
vue-tsc: ^3.2.5
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yaml: ^2.8.2
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
zod-to-json-schema: ^3.24.1
|
||||
|
||||
@@ -36,19 +36,8 @@ WORKFLOW = {
|
||||
}
|
||||
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
|
||||
|
||||
# API-format prompt with bare NaN/Infinity tokens (as Python's json.dumps emits
|
||||
# by default). The NaN variant fixtures omit the workflow field so the loader
|
||||
# must route through prompt-parsing, which trips JSON.parse on bare NaN.
|
||||
PROMPT_NAN = {
|
||||
'1': {
|
||||
'class_type': 'KSampler',
|
||||
'inputs': {'cfg': float('nan'), 'denoise': float('inf')},
|
||||
}
|
||||
}
|
||||
|
||||
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
|
||||
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
|
||||
PROMPT_NAN_JSON = json.dumps(PROMPT_NAN, separators=(',', ':'))
|
||||
|
||||
|
||||
def out(name: str) -> str:
|
||||
@@ -64,21 +53,15 @@ def make_1x1_image() -> Image.Image:
|
||||
return Image.new('RGB', (1, 1), (255, 0, 0))
|
||||
|
||||
|
||||
def build_exif_bytes(
|
||||
workflow_str: str | None = WORKFLOW_JSON,
|
||||
prompt_str: str | None = PROMPT_JSON,
|
||||
) -> bytes:
|
||||
def build_exif_bytes() -> bytes:
|
||||
"""Build EXIF bytes matching the backend's tag assignments.
|
||||
|
||||
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
|
||||
Pass ``None`` to omit a tag.
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
exif = img.getexif()
|
||||
if workflow_str is not None:
|
||||
exif[0x010F] = f'workflow:{workflow_str}'
|
||||
if prompt_str is not None:
|
||||
exif[0x0110] = f'prompt:{prompt_str}'
|
||||
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
|
||||
exif[0x0110] = f'prompt:{PROMPT_JSON}'
|
||||
return exif.tobytes()
|
||||
|
||||
|
||||
@@ -110,9 +93,6 @@ def generate_av_fixture(
|
||||
codec: str,
|
||||
rate: int = 44100,
|
||||
options: dict | None = None,
|
||||
*,
|
||||
prompt_json: str | None = PROMPT_JSON,
|
||||
workflow_json: str | None = WORKFLOW_JSON,
|
||||
):
|
||||
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
|
||||
path = out(name)
|
||||
@@ -120,10 +100,8 @@ def generate_av_fixture(
|
||||
stream = container.add_stream(codec, rate=rate)
|
||||
stream.layout = 'mono'
|
||||
|
||||
if prompt_json is not None:
|
||||
container.metadata['prompt'] = prompt_json
|
||||
if workflow_json is not None:
|
||||
container.metadata['workflow'] = workflow_json
|
||||
container.metadata['prompt'] = PROMPT_JSON
|
||||
container.metadata['workflow'] = WORKFLOW_JSON
|
||||
|
||||
sample_fmt = stream.codec_context.codec.audio_formats[0].name
|
||||
samples = stream.codec_context.frame_size or 1024
|
||||
@@ -197,63 +175,6 @@ def generate_webm():
|
||||
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
|
||||
|
||||
|
||||
def generate_nan_variants():
|
||||
"""Per-format fixtures carrying ONLY a NaN/Infinity-laden API prompt.
|
||||
|
||||
These force the loader through the prompt-parsing path, where Python's
|
||||
bare NaN/Infinity tokens trip JSON.parse.
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('prompt', PROMPT_NAN_JSON)
|
||||
img.save(out('with_nan_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_nan_metadata.png')
|
||||
|
||||
exif_nan = build_exif_bytes(workflow_str=None, prompt_str=PROMPT_NAN_JSON)
|
||||
|
||||
img = make_1x1_image()
|
||||
img.save(out('with_nan_metadata.webp'), 'WEBP', exif=exif_nan)
|
||||
report('with_nan_metadata.webp')
|
||||
|
||||
img = make_1x1_image()
|
||||
img.save(out('with_nan_metadata.avif'), 'AVIF', exif=exif_nan)
|
||||
report('with_nan_metadata.avif')
|
||||
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.flac', 'flac', 'flac',
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.opus', 'opus', 'libopus', rate=48000,
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.mp3', 'mp3', 'libmp3lame',
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
generate_av_fixture(
|
||||
'with_nan_metadata.webm', 'webm', 'libvorbis',
|
||||
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
|
||||
)
|
||||
|
||||
path = out('with_nan_metadata.mp4')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
||||
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
|
||||
'-movflags', 'use_metadata_tags',
|
||||
'-metadata', f'prompt={PROMPT_NAN_JSON}',
|
||||
path,
|
||||
], check=True)
|
||||
report('with_nan_metadata.mp4')
|
||||
|
||||
# Direct JSON file containing API-format prompt with bare NaN/Infinity.
|
||||
json_path = out('with_nan_metadata.json')
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
f.write(PROMPT_NAN_JSON)
|
||||
report('with_nan_metadata.json')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
@@ -264,5 +185,4 @@ if __name__ == '__main__':
|
||||
generate_mp3()
|
||||
generate_mp4()
|
||||
generate_webm()
|
||||
generate_nan_variants()
|
||||
print('Done.')
|
||||
|
||||
@@ -2,10 +2,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||
|
||||
import {
|
||||
zComfyWorkflow,
|
||||
zComfyWorkflow1
|
||||
} from '../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { zComfyWorkflow, zComfyWorkflow1 } from '@comfyorg/workflow-validation'
|
||||
import { zComfyNodeDef as zComfyNodeDefV2 } from '../src/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { zComfyNodeDef as zComfyNodeDefV1 } from '../src/schemas/nodeDefSchema'
|
||||
|
||||
@@ -57,4 +54,4 @@ fs.writeFileSync(
|
||||
JSON.stringify(nodeDefV2Schema, null, 2)
|
||||
)
|
||||
|
||||
console.log('JSON Schemas generated successfully!')
|
||||
console.warn('JSON Schemas generated successfully!')
|
||||
|
||||
97
scripts/prepare-workflow-validation.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const packageDir = path.join(repoRoot, 'packages', 'workflow-validation')
|
||||
const distDir = path.join(packageDir, 'dist')
|
||||
|
||||
interface SourcePackage {
|
||||
name: string
|
||||
version: string
|
||||
description?: string
|
||||
license?: string
|
||||
repository?: string
|
||||
homepage?: string
|
||||
dependencies?: Record<string, string>
|
||||
publishConfig?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface PnpmWorkspace {
|
||||
catalog?: Record<string, string>
|
||||
}
|
||||
|
||||
const sourcePackage = JSON.parse(
|
||||
fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')
|
||||
) as SourcePackage
|
||||
|
||||
const workspace = parseYaml(
|
||||
fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8')
|
||||
) as PnpmWorkspace
|
||||
const catalog = workspace.catalog ?? {}
|
||||
|
||||
function resolveCatalog(name: string): string {
|
||||
const sourceVersion = sourcePackage.dependencies?.[name]
|
||||
if (sourceVersion && sourceVersion !== 'catalog:') return sourceVersion
|
||||
const version = catalog[name]
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`Could not resolve catalog version for ${name}. ` +
|
||||
`Expected entry under \`catalog:\` in pnpm-workspace.yaml.`
|
||||
)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
const distPackage = {
|
||||
name: sourcePackage.name,
|
||||
version: sourcePackage.version,
|
||||
description: sourcePackage.description,
|
||||
license: sourcePackage.license,
|
||||
repository: sourcePackage.repository,
|
||||
homepage: sourcePackage.homepage,
|
||||
type: 'module',
|
||||
main: './index.js',
|
||||
module: './index.js',
|
||||
types: './index.d.ts',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './index.d.ts',
|
||||
import: './index.js'
|
||||
},
|
||||
'./linkRepair': {
|
||||
types: './linkRepair.d.ts',
|
||||
import: './index.js'
|
||||
},
|
||||
'./linkTopology': {
|
||||
types: './linkTopology.d.ts',
|
||||
import: './index.js'
|
||||
},
|
||||
'./workflowSchema': {
|
||||
types: './workflowSchema.d.ts',
|
||||
import: './index.js'
|
||||
},
|
||||
'./serialised': {
|
||||
types: './serialised.d.ts',
|
||||
import: './index.js'
|
||||
}
|
||||
},
|
||||
files: ['*.js', '*.d.ts'],
|
||||
publishConfig: sourcePackage.publishConfig ?? { access: 'public' },
|
||||
dependencies: {
|
||||
zod: resolveCatalog('zod'),
|
||||
'zod-validation-error': resolveCatalog('zod-validation-error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(distDir, 'package.json'),
|
||||
JSON.stringify(distPackage, null, 2) + '\n'
|
||||
)
|
||||
console.warn(`Prepared ${distPackage.name}@${distPackage.version} in dist/`)
|
||||
@@ -1,91 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assert, setAssertReporter } from '@/base/assert'
|
||||
|
||||
describe('assert', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
setAssertReporter(null)
|
||||
})
|
||||
|
||||
it('does nothing when condition is true', () => {
|
||||
expect(() => assert(true, 'should not throw')).not.toThrow()
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs console.error when condition is false', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
assert(false, 'test message')
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: test message'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws in DEV mode when condition is false', () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
const reporter = vi.fn()
|
||||
setAssertReporter(reporter)
|
||||
expect(() => assert(false, 'dev error')).toThrow(
|
||||
'[Assertion failed]: dev error'
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: dev error'
|
||||
)
|
||||
expect(reporter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw in non-DEV mode when condition is false', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
expect(() => assert(false, 'non-dev error')).not.toThrow()
|
||||
})
|
||||
|
||||
it('calls registered reporter in non-DEV mode with formatted message', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const reporter = vi.fn()
|
||||
setAssertReporter(reporter)
|
||||
assert(false, 'reporter message')
|
||||
expect(reporter).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: reporter message'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call reporter when condition is true', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const reporter = vi.fn()
|
||||
setAssertReporter(reporter)
|
||||
assert(true, 'no call')
|
||||
expect(reporter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles null reporter gracefully in non-DEV mode', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
setAssertReporter(null)
|
||||
expect(() => assert(false, 'null reporter')).not.toThrow()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: null reporter'
|
||||
)
|
||||
})
|
||||
|
||||
it('swallows reporter exceptions in non-DEV mode', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const reporter = vi.fn(() => {
|
||||
throw new Error('reporter blew up')
|
||||
})
|
||||
setAssertReporter(reporter)
|
||||
expect(() => assert(false, 'safe under reporter failure')).not.toThrow()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion failed]: safe under reporter failure'
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Assertion reporter failed]',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
type AssertReporter = (message: string) => void
|
||||
|
||||
let reporter: AssertReporter | null = null
|
||||
|
||||
/**
|
||||
* Register a reporter for assertion failures in non-DEV environments.
|
||||
* Called once at app startup by platform/ or higher layers to wire in
|
||||
* Sentry, toast notifications, etc.
|
||||
*/
|
||||
export function setAssertReporter(fn: AssertReporter | null): void {
|
||||
reporter = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized invariant assertion.
|
||||
*
|
||||
* - Always: console.error
|
||||
* - DEV: throws (surfaces bugs immediately)
|
||||
* - Otherwise: delegates to registered reporter (Sentry, toast, etc.)
|
||||
*/
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (condition) return
|
||||
|
||||
const formatted = `[Assertion failed]: ${message}`
|
||||
console.error(formatted)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
throw new Error(formatted)
|
||||
}
|
||||
|
||||
try {
|
||||
reporter?.(formatted)
|
||||
} catch (error) {
|
||||
console.error('[Assertion reporter failed]', error)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -16,6 +16,14 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/dialog', () => ({
|
||||
default: {
|
||||
name: 'Dialog',
|
||||
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
|
||||
props: ['visible']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
@@ -24,29 +32,8 @@ vi.mock('primevue/selectbutton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: { name: 'Dialog', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
|
||||
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
|
||||
default: { name: 'DialogOverlay', template: '<div />' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
|
||||
default: { name: 'DialogContent', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
|
||||
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({
|
||||
default: { name: 'DialogFooter', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
|
||||
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
|
||||
default: { name: 'DialogClose', template: '<button />' }
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: { name: 'Divider', template: '<hr />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
|
||||
|
||||
@@ -1,111 +1,72 @@
|
||||
<template>
|
||||
<Dialog v-model:open="visible" :modal="false">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
size="md"
|
||||
:aria-labelledby="titleId"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle :id="titleId">
|
||||
{{ $t('g.customizeFolder') }}
|
||||
</DialogTitle>
|
||||
<DialogClose />
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-icon" class="text-sm font-medium">
|
||||
{{ $t('g.icon') }}
|
||||
</label>
|
||||
<SelectButton
|
||||
id="customization-icon"
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customization-color" class="text-sm font-medium">
|
||||
{{ $t('g.color') }}
|
||||
</label>
|
||||
<ColorCustomizationSelector
|
||||
id="customization-color"
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
<Dialog v-model:visible="visible" :header="$t('g.customizeFolder')">
|
||||
<div class="p-fluid">
|
||||
<div class="field icon-field">
|
||||
<label for="icon">{{ $t('g.icon') }}</label>
|
||||
<SelectButton
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div class="field color-field">
|
||||
<label for="color">{{ $t('g.color') }}</label>
|
||||
<ColorCustomizationSelector
|
||||
v-model="finalColor"
|
||||
:color-options="colorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="textonly" @click="resetCustomization">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('g.reset') }}
|
||||
</Button>
|
||||
<Button autofocus @click="confirmCustomization">
|
||||
<i class="pi pi-check" />
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { ref, useId, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { initialIcon, initialColor } = defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
initialIcon?: string
|
||||
initialColor?: string
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', icon: string, color: string): void
|
||||
}>()
|
||||
|
||||
const titleId = useId()
|
||||
|
||||
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
|
||||
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
|
||||
// overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
|
||||
|
||||
function onPointerDownOutside(
|
||||
event: CustomEvent<{ originalEvent: PointerEvent }>
|
||||
) {
|
||||
const target = event.detail.originalEvent.target
|
||||
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
@@ -134,22 +95,30 @@ const defaultIcon = iconOptions.find(
|
||||
)
|
||||
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
|
||||
const resetCustomization = () => {
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
|
||||
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
const confirmCustomization = () => {
|
||||
emit('confirm', selectedIcon.value.value, finalColor.value)
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(newValue) => {
|
||||
() => props.modelValue,
|
||||
(newValue: boolean) => {
|
||||
if (newValue) {
|
||||
resetCustomization()
|
||||
}
|
||||
@@ -166,4 +135,10 @@ watch(
|
||||
.p-selectbutton .p-button .pi {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] -translate-1/2 rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
|
||||
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<AsyncSearchInput
|
||||
<FormSearchInput
|
||||
v-model="searchInput"
|
||||
:searcher="applySearchQuery"
|
||||
:debounce-ms="400"
|
||||
@@ -412,7 +412,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<hr class="border-t border-border-subtle" />
|
||||
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
|
||||
<Divider />
|
||||
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
|
||||
<pre class="wrap-break-word whitespace-pre-wrap">{{
|
||||
reportContent
|
||||
}}</pre>
|
||||
</div>
|
||||
<hr class="border-t border-border-subtle" />
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<div class="flex justify-end gap-4">
|
||||
<FindIssueButton
|
||||
@@ -62,6 +62,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||