Compare commits

..

11 Commits

Author SHA1 Message Date
Christian Byrne
0c0258e70d Merge branch 'main' into glary/workflow-validation-package 2026-05-12 18:32:29 -07:00
Glary-Bot
208c18edec refactor(validation): drop dead linkObj/extendLink, index nodes by id, test repair happy-path
Address Christian's review:
- linkRepair: remove the now-orphaned `linkObj`/`extendLink` helper.
  `toLinkContext` already gives us the structured tuple we need; the
  literal-vs-object branching it replaced is no longer reachable, so
  the assignment + `void linkObj` lint silencer + `extendLink`
  function were all dead weight.
- linkTopology: build a `Map<string, SerialisedNode>` once per
  validation pass and look node up by id instead of `Array.find`
  on every link. Drops the algorithm from O(nodes × links) to
  O(nodes + links); the string-keyed map preserves the existing
  loose-equality behaviour for numeric vs. string node ids.
- linkRepair.test: add a happy-path repair test that asserts both
  `patched` and `deleted` counts in the `RepairResult` are
  non-zero and that the graph mutations actually landed (origin
  link list updated, dangling link removed).
2026-05-11 20:16:20 +00:00
bymyself
28d11c7161 fix(storybook): add aliases for workflow-validation package paths
The storybook vite config was missing the path aliases that vite.config.mts
defines for moved package paths, causing storybook-build to fail with:

  [UNLOADABLE_DEPENDENCY] Could not load
  src/platform/workflow/validation/schemas/workflowSchema

Mirror the @/utils/linkFixer and @/platform/workflow/validation/schemas/workflowSchema
aliases from vite.config.mts so storybook resolves them to packages/workflow-validation.
2026-05-07 15:52:06 +00:00
Glary-Bot
5adeb0c9fe test(validation): tighten test patterns per pythongosssss review
- prepare-workflow-validation.ts: parse pnpm-workspace.yaml with the
  `yaml` package instead of a regex; safer for any future formatting
  drift in the catalog block.
- useWorkflowValidation: use the global `structuredClone` directly
  instead of the in-tree `clone` helper. The helper falls back to
  JSON parse/stringify in legacy environments, which is irrelevant
  here (vitest happy-dom + production targets all support
  `structuredClone`), and removing the import drops a transitive
  dep on `@/scripts/utils` which made the composable harder to
  unit-test.
- linkRepair.test.ts: drop the synthetic 'patched view diverges'
  fixture (it didn't actually trigger the throw, exactly the
  false-positive pythongosssss called out) and the duplicate
  describeTopologyError-via-abort block (already covered in
  linkTopology.test.ts). The abort path is now exercised structurally
  in useWorkflowValidation.test.ts where a mocked
  `LinkRepairAbortedError` flows through the catch.
- linkRepair.test.ts: keep the live-graph cast — the live-graph
  branch in `repairLinks` genuinely accesses `graph.links` as a
  record at runtime even though the published type is an array, so
  the test fixture needs to mirror that mismatch.
- useWorkflowValidation.test.ts: use `createTestingPinia({
  stubActions: false })`; replace the per-mock clears with
  `vi.clearAllMocks()`; trim the vue-i18n mock down to identity
  `t()` (matches what real i18n with empty messages would do); add
  a JSON-snapshot test that proves the original graph is byte-equal
  before and after an aborted repair (the prior `!== wf` assertion
  only proved a different reference, not preservation).
2026-05-07 15:50:02 +00:00
Glary-Bot
145fd3909f test(validation): add unit coverage for useWorkflowValidation and linkRepair abort paths
Codecov flagged the patch at 53% with the new `useWorkflowValidation`
composable at 1.85% and `linkRepair`'s new abort branches uncovered.
This adds:

- `useWorkflowValidation.test.ts` (8 tests) covering: schema-fail
  fallback, no-error happy path, topology summary toast capping at
  `TOPOLOGY_TOAST_LIMIT`, links-fixed success toast, abort returning
  `null`, re-throw of unexpected exceptions, the clone-before-repair
  contract, and silent-mode suppression. `vue-i18n`, the toast store,
  the package, the schema validator, and `@/scripts/utils` are all
  mocked out.
- `linkRepair.test.ts` (5 tests) covering the
  `LinkRepairAbortedError` shape, its formatted message for every
  `TopologyError.kind`, the live-graph branch (`getNodeById` plus
  record-shaped `graph.links`), and the not-found `continue` path
  in the splice loop.
2026-05-04 21:41:32 +00:00
Glary-Bot
958b7eb486 fix(validation): use tsconfig project reference instead of allowDefaultProject
Address Christian's review: instead of widening eslint's
`allowDefaultProject` list, give the package's `vite.config.mts`
its own `tsconfig.node.json` (composite reference) and let
typescript-eslint's `projectService` discover it via the package's
`tsconfig.json` references. Reverts the eslint config change.
2026-05-04 21:22:33 +00:00
Glary-Bot
ea2e0fee9d fix(validation): clone graph before repair and i18n the topology detail strings
- Clone the validated graph before passing it to `repairLinks`. Repair
  is mutating, and after the new re-throw path, an aborted repair would
  leave the caller's graph half-mutated even though
  `validateWorkflow` returns `null` to signal fallback.
- Render every `TopologyError` via `vue-i18n` keys
  (`validation.topology.tuple` plus per-kind templates) in the
  composable, including the abort toast detail and the per-link summary
  lines. The package keeps `describeTopologyError` as the structured
  English form for console logs, CI scripts, and the future backend
  validator.
2026-05-03 06:54:14 +00:00
Glary-Bot
1f795c7fbe fix(validation): address CodeRabbit review feedback
- linkRepair: `continue` on `idx === -1` so we don't `splice(-1, 1)`
  and clobber the last unrelated link.
- linkTopology: report origin- and target-slot-out-of-bounds errors
  independently for the same link instead of bailing after the first.
- useWorkflowValidation: re-throw non-`LinkRepairAbortedError` failures
  so unrelated bugs don't get silently downgraded to an aborted load.
- useWorkflowValidation: route every toast string through
  `vue-i18n` (`validation.topology.*` keys in
  `src/locales/en/main.json`).
- Convert `scripts/prepare-workflow-validation.js` to TypeScript per
  repo policy and run it via `tsx`. Make the catalog regex robust by
  appending a synthetic terminator instead of changing the existing
  `\\n\\S` semantics, which broke for the multiline catalog block.
- Add object-form link tests covering the both-shapes contract.
2026-05-03 06:33:07 +00:00
Glary-Bot
0eeddb6669 chore: drop publish workflow YAML pending workflows-permission grant
The workflow file requires `workflows` permission on the GitHub App
posting the PR, which isn't granted today. The release workflow can be
added in a follow-up commit by a maintainer; the package itself is
already buildable via `pnpm --dir packages/workflow-validation run build`.
2026-05-03 06:06:09 +00:00
Glary-Bot
5ee6e627b9 fix(validation): make workflow-validation package buildable for npm publish
Address code review feedback:

- Annotate `zGraphDefinitions.subgraphs`'s lazy resolver with the same
  explicit `z.ZodArray<z.ZodType<SubgraphDefinitionBase<...>>>` shape
  the recursive `zSubgraphDefinition` already uses. This breaks the
  TS7056 'inferred type exceeds maximum length' error when emitting
  declarations for `zComfyWorkflow`.
- Drop `rollupTypes` from the package's `vite-plugin-dts` config.
  Per-file `.d.ts` emit avoids both the api-extractor entry-point
  failure and the recursive-type rollup blowup; `index.d.ts` re-exports
  per-file declarations so consumers still see one entry.
- Restore the full subpath export surface in the published manifest
  (`./linkRepair`, `./linkTopology`, `./workflowSchema`,
  `./serialised`) and resolve catalog versions for runtime deps so the
  published package matches the workspace contract.
- Ignore `*.tsbuildinfo` so package builds don't litter the index.
2026-05-02 04:58:02 +00:00
Glary-Bot
72ac6773a1 feat(validation): split topology validation from link repair into publishable nx package
Extract workflow zod schemas, link topology validator, and link repair
into a new `@comfyorg/workflow-validation` package under `packages/`.
The package has zero litegraph coupling — it operates on plain
serialised JSON shapes — so it can be consumed by Node.js CI scripts
and a future backend validator.

Behaviour changes:

- Replace the four `'Error. Expected node to match patched data.'`
  invariant throws in the link fixer with `LinkRepairAbortedError`,
  which carries a structured `TopologyError` describing the offending
  `[linkId, src, srcSlot, tgt, tgtSlot]`.
- Add a pure `validateLinkTopology(graph)` that walks the link table
  and reports out-of-bounds slots, missing nodes, and endpoint
  mismatches as a structured `TopologyError[]`. This catches the
  seedance-style breakage (links targeting slots that don't exist on
  the node) which the schema cannot detect.
- Surface topology errors via toast when `Comfy.Validation.Workflows`
  is enabled, instead of swallowing them with `console.error`.
  Unrepairable workflows raise an `error` toast with structured
  details and `useWorkflowValidation` returns `null` so the caller
  falls back to the original graph.

Migration is via tsconfig + vite path aliases mirroring the existing
`@/utils/formatUtil` precedent, so the 76 importers of
`workflowSchema` and the lone importer of `linkFixer` continue to
compile without changes.

Adds `release-npm-workflow-validation.yaml`, a `workflow_dispatch`
publisher mirroring the existing `release-npm-types` pattern, so a
follow-up PR on Comfy-Org/workflow_templates can pin the published
package for per-template topology CI.
2026-05-02 04:41:56 +00:00
241 changed files with 3057 additions and 8252 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -16,6 +16,7 @@ yarn.lock
.eslintcache
.prettiercache
.stylelintcache
*.tsbuildinfo
node_modules
.pnpm-store

View File

@@ -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'

View File

@@ -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:

View File

@@ -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)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -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' }
]
}

View File

@@ -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"
>
&nbsp;
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
}}
</p>
<ul class="space-y-2">
<li

View File

@@ -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>

View File

@@ -1 +0,0 @@
export const SHOW_FREE_TIER = false

View File

@@ -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',

View File

@@ -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',

View File

@@ -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)
})
})

View File

@@ -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.'
)
}

View File

@@ -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
}

View File

@@ -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
}

Binary file not shown.

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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()
}
}

View File

@@ -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
]

View File

@@ -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. */

View File

@@ -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()
}
}

View 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)
}
}
}

View File

@@ -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

View File

@@ -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)
}

View 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()
}
})

View File

@@ -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' })
}
})

View File

@@ -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',

View File

@@ -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()
}
})

View File

@@ -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()
}

View File

@@ -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()
}

View 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)
}

View File

@@ -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('..')
}
}

View File

@@ -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')
}
})
}
)

View File

@@ -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()
}

View File

@@ -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 }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -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
}) => {

View File

@@ -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)
}
})
})
}
}
)

View File

@@ -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 }) => {

View File

@@ -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()
}
)
}
)

View File

@@ -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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -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 ({

View File

@@ -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 }) => {

View File

@@ -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')
})
})

View File

@@ -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()
})

View File

@@ -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()

View File

@@ -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')

View File

@@ -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 })
})
})

View File

@@ -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
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -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' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -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;

View File

@@ -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:"
},

View File

@@ -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}]");

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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(),

View File

@@ -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(

View File

@@ -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) ||

View 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"
]
}
}

View 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'

View 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)
})
})

View File

@@ -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
}

View 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)
})
})

View 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
}

View 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>
}

View File

@@ -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({

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "dist/.tsnode",
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.mts"]
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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.')

View File

@@ -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!')

View 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/`)

View File

@@ -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)
)
})
})

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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', () => ({

View File

@@ -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>

View File

@@ -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"

View File

@@ -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">

View File

@@ -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'

View File

@@ -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'

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