Compare commits

..

32 Commits

Author SHA1 Message Date
huang47
73e7730797 test: merge base change-detector fixes into consolidated suites 2026-07-06 12:08:52 -07:00
huang47
db126a7648 test: replace change-detector default assertions with behavioral checks 2026-07-06 12:03:18 -07:00
huang47
7fc2375067 test: cover deprecated graph getter 2026-07-03 21:11:26 -07:00
huang47
06988336ad test: use shared litegraph node mock helper in app tests 2026-07-03 14:58:15 -07:00
huang47
92aa801c82 test: replace fromAny and double assertions with typed fixtures in app and gltf tests 2026-07-03 14:52:18 -07:00
huang47
036899cc15 test: fix fromAny and double-cast violations in litegraphService.test.ts
Replace fromAny imports/usages and `as unknown as T` patterns with
fromPartial<T>() or single-cast equivalents, following the project's
test-conventions rules. Includes updating mockCanvas.graph.nodes type
to allow undefined and using capturedCtors to avoid double-cast on the
SubgraphNode constructor.
2026-07-03 14:52:18 -07:00
huang47
dbfc27f1fc test: consolidate coverage suites and clean up superseded test scaffolding
Fold sibling suites back into their canonical files (app.test.ts,
litegraphService.test.ts), replace module-level global.fetch/global.URL
mocks with vi.stubGlobal, apply helper refactors (avif/gltf box
builders, searchAndReplace createGraph, treeUtil createNode hoist), and
drop tests superseded by rewritten variants.
2026-07-03 14:52:18 -07:00
huang47
aa38b5f478 test: address review feedback on coverage tests
- Reuse shared litegraph/subgraph test factories instead of hand-rolled mocks
- Seed required_input_missing errors via shared executionErrorTestUtils helper
- Use createTestingPinia in executionErrorStore derived-state tests
- Drop change-detector assertion on the zeroUuid constant
- Replace fromAny/as-unknown-as with fromPartial or honest signatures
  (widen ComfyButtonGroup.insert and loadGraphData to inputs they already handle)
- Isolate ChangeTracker.init prototype/listener side effects per test
- Reset fetchJobs mock implementations and fake timers between tests
2026-07-03 14:45:57 -07:00
huang47
11671dac16 fix: revert accidental inclusion of unlanded lint/test-conventions-warnings changes
The eslint.config.ts convention rules for test files belong on the
lint/test-conventions-warnings branch and should not ship in this PR.
2026-07-03 14:45:57 -07:00
huang47
df78c566a5 test: replace unsafe type assertions with fromPartial in coverage tests
Replace double assertions (as unknown as T), as never, fromAny, and
partial-literal casts with fromPartial<T>() from @total-typescript/shoehorn
throughout the coverage-expansion test suite. Fix cross-test prototype
pollution in app.core.test.ts by keeping the updateVueAppNodeDefs spy on
the app instance rather than its prototype. Fix subgraphNavigationStore
tests to use fromPartial<Subgraph> instead of as never for mock assignment.
2026-07-03 14:45:57 -07:00
huang47
64fad7e2c4 fix: widen types to allow null/undefined in serialization and graph APIs
- ISerialisedGraph.links now allows null entries (matching runtime format)
- LGraph.configure skips null link entries instead of crashing
- graphEqual accepts nullable arguments
- getNodeByLocatorId accepts null rootGraph
- fixBadLinks handles null link entries in arrays
- applyClasses accepts null/undefined classList
- app.graph getter returns LGraph | undefined (removes non-null assertion)
- ComfyApp.clipspace_return_node gets explicit LGraphNode | null type
- PartnerNodesList uses rootGraph instead of deprecated graph getter
- api.addEventListener/removeEventListener accept EventListenerObject
- Remove @ts-expect-error now that clipspace_return_node is properly typed
2026-07-03 14:45:57 -07:00
huang47
efabd24de7 test: cover the root-graph branch in widget promotion options test 2026-07-03 14:45:56 -07:00
huang47
e106a0ad8d test: address review findings on new coverage tests
Exercise the real drain path in autoQueueService test, align
executionErrorStore no-op test with its fixture, reset ComfyApp
clipspace state between litegraphService tests, restore app.canvas and
global stubs in finally blocks, strengthen subgraphStore compact-detail
assertion, fix imprecise imageUtil test title.
2026-07-03 14:45:56 -07:00
huang47
af64a2397f test: restructure coverage additions to be purely additive vs main
Restore all pre-existing test lines; new coverage lands as pure
additions. Suites whose mock architecture conflicts with the original
files move to sibling files (app.core.test.ts,
litegraphService.core.test.ts). Rewrites/refactors of existing tests
and helpers are deferred to a follow-up PR.
2026-07-03 14:45:56 -07:00
huang47
6e63cf07b9 fix(tests): resolve typecheck errors in coverage tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:56 -07:00
huang47
73edbc1fa7 test: cover critical missed branches 2026-07-03 14:45:56 -07:00
Robin Huang
d85ce2bf9e feat: add custom nodes waitlist survey to Manager button on Cloud (#13135)
## Summary

On Comfy Cloud, show the Manager button and open a hosted survey in the
manager modal (in place of the local node manager) so we can gauge
demand for custom nodes on Cloud.

## Changes

- **What**: `TopMenuSection` shows the Manager button when `isCloud`;
clicking it opens `ManagerSurveyDialog`, which embeds a PostHog hosted
survey via iframe. The survey URL comes per-environment from cloud
config (`manager_survey_url`), with the logged-in user's `distinct_id`
appended so responses link to the user. Includes loading/error states
and PostHog's `posthog:survey:height` iframe auto-resize.
- **Dependencies**: none

## Review Focus

- Survey URL is sourced from `remoteConfig.manager_survey_url` (must be
set per environment in cloud config); falls back to an error state when
unset or malformed.
- iframe embedding requires the PostHog survey to be `external_survey`
type with embedding enabled.
2026-07-03 18:12:34 +00:00
Benjamin Lu
fa87c46f90 chore: remove PreToolUse pnpm-enforcement hooks (#13422)
## Summary

Removes the Claude Code PreToolUse hooks added in #11201. Their `if`
patterns blocked any bash command the static pattern parser could not
fully resolve — loop variables, `$(...)`/backtick substitution,
heredocs, `${...}` expansions — with a misleading error naming a random
unrelated tool. Transcript analysis across a month of sessions found 37
hook firings: 1 true positive, 36 false positives (~97%), including
blocking `pnpm typecheck ... | tail` itself.

## Changes

- **What**: Delete `.claude/settings.json` (it contained only the hook
config) and the script-based replacement from earlier revisions of this
PR.
- Earlier revisions replaced the hooks with a stdin-inspecting matcher
script, but the hooks' original rationale — protecting Nx task
orchestration back when `test:unit` was `nx run test` — disappeared when
Nx was removed in #12355 and the pnpm scripts became direct tool
invocations. The remaining value (nudging agents toward pnpm scripts)
does not justify maintaining a bash-parsing matcher with its own edge
cases.

## Review Focus

- Agents can now run `npx tsc` / `npx vitest` etc. without being
redirected; the pnpm-script convention remains documented in AGENTS.md,
which is what agents follow in practice.
2026-07-03 06:57:05 +00:00
Benjamin Lu
941f151520 fix: avoid duplicate unit coverage execution (#13423)
## Summary

Stop running the full Vitest suite twice in unit CI. The critical
coverage gate is now a glob-keyed `coverage.thresholds` entry enforced
during the single `pnpm test:coverage` run, instead of a second
`COVERAGE_CRITICAL=true vitest run --coverage` pass.

## Changes

- **What**: All critical directories form one brace-expanded glob key in
`coverage.thresholds`; Vitest aggregates the matching files into a
single bucket and checks the existing thresholds (69/60/67/70) against
it during the normal coverage run. Untested files matching
`coverage.include` are counted at 0%, preserving the previous gate's
semantics.
- **What**: Narrows the litegraph coverage exclusion from a blanket
`src/lib/litegraph/**` to the non-critical subfolders, so the critical
litegraph folders (`node`, `subgraph`, `utils`) are present in the
coverage report the thresholds read.
- **What**: Removes the `test:coverage:critical` script, the
`COVERAGE_CRITICAL` env branch in `vite.config.mts`, and the separate CI
gate step.

## Notes

- The normal coverage report now includes the critical litegraph
folders, so the Codecov `unit` flag and the coverage Slack baseline will
show a one-time shift.
- Filtered local runs (`pnpm test:coverage <file>`) fail the gate since
most critical files are uncovered; a full `pnpm test:coverage`
reproduces CI exactly.

Validation:
- `pnpm typecheck`, `pnpm exec eslint vite.config.mts`, `pnpm
format:check`, `pnpm knip` (pre-push)
- Smoke: `pnpm vitest run --coverage src/utils/colorUtil.test.ts` —
tests pass, then the gate fails all four metrics against the critical
bucket and exits 1, confirming enforcement happens inside the single run
- Full-suite gate numbers should be confirmed in CI
2026-07-03 05:43:05 +00:00
Benjamin Lu
df5f5b3367 feat: identify auth users to Syft (#13311)
## Summary

Identifies Cloud auth users to Syft via the required `identify(email, {
source })` handoff so Syft enrichment reliably attaches to signup/login
users instead of relying only on GTM page-load capture.

## Changes

- **What**: Adds a cloud-only Syft telemetry provider that reads
`syftdata_source_id` from `remoteConfig`, lazy-loads the Syft SDK
(reusing an already-loaded GTM Syft client when present), and calls
`identify` with `source: 'signup'` or `source: 'login'` on auth and on
session restore. `trackUserLoggedIn()` dedupes against the email already
handled by `trackAuth()` so a fresh login is not identified twice.
- **Dependencies**: None.

## Review Focus

- Preserves the FE-945 startup-blocker fix: the constructor reads only
the `remoteConfig` ref (a plain reactive ref, not Pinia) and never
touches current-user state; the user-email lookup happens only in
`trackUserLoggedIn()`, after app/auth setup. The source id is present at
construction because `main.ts` awaits the anonymous
`refreshRemoteConfig` before `initTelemetry`, and a later authenticated
refresh is picked up reactively on the next `ensureSyftClient()` call.
- SDK loader is idempotent with the current GTM Syft tag during rollout
(one script per `SYFT_SRC`). On load failure it clears its own stub —
guarded by an identity check so it never evicts a real client another
loader installed — letting a subsequent call retry. Long-term cleanup is
to keep one loader path per surface.
- Acceptance should include staging Network verification for
`https://e2.sy-d.io/events` payloads containing an `identify` event for
Google, GitHub, and email auth.

Linear: GTM-168
2026-07-03 05:10:52 +00:00
imick-io
156f2f59b7 feat(website): swap nav featured card to Comfy MCP (#13388)
## Summary

Repurpose the Products dropdown featured card to promote Comfy MCP.

## Changes

- **What**: Update the nav featured card title ("NEW: COMFY MCP"), alt
text, image asset (`mcp-card.webp`), and CTA ("GET STARTED") in
`mainNavigation.ts`; route the CTA to the localized `/mcp` page via
`routes.mcp`. All copy is i18n'd (en + zh-CN) in `translations.ts`,
adding a reusable `cta.getStarted` key.

## Review Focus

- CTA uses a new reusable `cta.getStarted` key rather than the
section-scoped `mcp.setup.label`, and routes to the internal
`routes.mcp` so non-en locales resolve to `/{locale}/mcp`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:01:55 +00:00
nav-tej
d855466fdf fix(website): cap contact intro text width and space it from the form (#13420)
*PR Created by the Glary-Bot Agent*

---

## Summary

On `comfy.org/contact` the intro copy ("Create powerful workflows, scale
without limits." + description) ran right up against the HubSpot form
fields on desktop. The two `lg:w-1/2` columns had no gap between them
and the left column had no max-width, so long description text extended
almost to the form's edge.

- Add `lg:gap-16` between the two columns in `FormSection.vue`, matching
the pattern already used by `common/ContentSection.vue` and
`legal/LegalContentSection.vue`.
- Wrap the intro text block (badge + heading + description) in an
`lg:max-w-xl` container so the copy no longer stretches into the gap.
The illustration below keeps its full-column bleed via the existing
`lg:-ml-20`.
- Mobile (`<lg`) layout is unchanged — all new classes are
`lg:`-prefixed.

## Verification

Screenshots taken via Playwright against the local `apps/website` dev
server:

- Desktop (1512×900) — intro text now caps at a comfortable line length
with a clear gap to the form.
- 1024px — still works at the `lg:` breakpoint.
- 375px mobile — visually identical to before.

Also ran `pnpm format` and `pnpm --filter @comfyorg/website typecheck`
(0 errors). Three pre-existing
`better-tailwindcss/enforce-consistent-class-order` lint warnings on
this file exist on `main` and were left untouched.

- Fixes contact-page layout complaint from #website-and-docs (July 2)

## Screenshots

![Contact page after fix at 1512px — intro text capped with clear gap to
form](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049233475-b5932d36-9087-4689-a7ea-925bad2f09ff.png)

![Contact page after fix at 1024px
viewport](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234208-8ef2cdac-b929-4c4d-b60a-e794f989fd76.png)

![Contact page after fix at 375px mobile viewport — layout
unchanged](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234909-c502d978-2586-4ce7-b54a-d96fc759d306.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-07-03 03:51:31 +00:00
AustinMroz
9d5719871a Compact vue nodes (#12886)
Updates vue nodes to be compact. 

This PR does modify the sizing of the asset dropdown (as used on nodes
like "Load Image"). There are outstanding concerns about the visibility
of the upload button and ongoing work to address this.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5c866d6f-d83e-40e1-9d87-17b990d94e04"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/2a809e90-13aa-4f95-8b73-3f20b02fd9a1"
/>|

Subsumes #12678

---------

Co-authored-by: Alex <alex@Alexs-MacBook-Pro.local>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-03 02:31:41 +00:00
ShihChi Huang
7610a61250 test: cover queue display formatting (#13089)
## Summary

Add direct tests for queue job display formatting.

Base: `main`

## Changes

- Covers state icons, pending/initializing labels, running progress,
completed local/cloud output, fallback completed titles, and failed
display.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:unit src/utils/queueDisplay.test.ts --run` | no direct
queue display test file |  13 passed |

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/queueDisplay.ts` 22.72% -> 79.54% (+56.82%); overall branches
52.95% -> 53.03% (+0.08%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change; no runtime or production code modified.
> 
> **Overview**
> Adds **`src/utils/queueDisplay.test.ts`**, a Vitest suite that
exercises **`iconForJobState`** and **`buildJobDisplay`** from
`queueDisplay.ts` without touching UI or production logic.
> 
> Tests use small **`createJob` / `createTask` / `createCtx`** helpers
with a stub **`t`** and clock formatter so expectations assert i18n keys
and formatted values. Coverage includes pending “added to queue” hint,
queued/initializing labels, active vs inactive running progress,
completed local preview vs cloud duration, completed title fallback, and
failed rows with **`showClear`** behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6260c101e5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:39:24 +00:00
ShihChi Huang
47c8b09ebf test: 2/x cover fuse search ranking (#13087)
## Summary

Add direct tests for `fuseUtil` search ranking and filter behavior.


## Changes

- Covers ranking tiers, deprecated penalties, post-processing, empty
queries, auxiliary score comparison, and filter wildcard/comma matching.

## Test Results

- `pnpm test:unit src/utils/fuseUtil.test.ts`: 7 passed.
- `pnpm typecheck`: passed.
- `pnpm test:coverage`: 876 test files passed; 11,759 passed / 8
skipped.

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/fuseUtil.ts` 81.48% -> 92.59% (+11.11%); overall branches
52.93% -> 52.95% (+0.02%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 8bf748d1a4. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-07-02 22:30:50 +00:00
ShihChi Huang
65b4c53bcb ci: skip website report deploy for fork PRs (#13344)
## Summary

Skip the website e2e report/deploy step for fork PRs, which lack the
deploy secrets and otherwise fail the job.

## Changes

- **What**: Guard the report/deploy step's `if:` in
`ci-website-e2e.yaml` so it runs only when the event is not a fork pull
request.
- **Breaking**: none. CI-config only.

## Review Focus

CI-config only — no test or coverage change. Confirms fork PRs no longer
fail on the deploy step.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI workflow condition only; no application or test logic changes.
> 
> **Overview**
> **Website E2E CI** no longer runs the **Deploy report to Cloudflare**
step on pull requests from forks.
> 
> The step’s `if:` still requires `always()` and `!cancelled()`, and now
also requires either a non–pull-request event or a PR whose head repo is
**not** a fork. Playwright tests and artifact upload are unchanged; only
the wrangler deploy (which needs `CLOUDFLARE_*` secrets) is skipped for
fork PRs so those runs don’t fail when secrets aren’t available.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02a4ab0769. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:30:03 +00:00
ShihChi Huang
15b31d69ea ci: skip secret-backed CI deploys for fork PRs (#13291)
## Summary

Skip secret-backed CI deploy and dispatch work for fork PRs so missing
repo secrets do not fail otherwise valid checks.

## Changes

- **What**: Guard Website E2E report deploy, Vercel website preview
deploy, cloud build dispatch, cloud cleanup dispatch, and Storybook
Chromatic deploy so PR paths only run for same-repo PRs.
- **Dependencies**: None

## Why

Fork `pull_request` runs do not receive repository secrets. Several CI
jobs already separated normal validation from privileged follow-up work,
but some deploy or dispatch steps could still run on fork PRs and fail
only because their secret-backed integration token was empty.

The existing Website E2E fork guard only protected the PR comment job.
It did not protect the earlier Cloudflare report deploy step inside
`website-e2e`, which uses `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.

The same failure mode existed in these CI jobs:

- `ci-vercel-website-preview.yaml`: preview deploy uses Vercel and
website API secrets.
- `cloud-dispatch-build.yaml`: preview dispatch uses
`CLOUD_DISPATCH_TOKEN` to call `Comfy-Org/cloud`.
- `cloud-dispatch-cleanup.yaml`: preview cleanup dispatch uses
`CLOUD_DISPATCH_TOKEN`.
- `ci-tests-storybook.yaml`: Chromatic deploy uses
`CHROMATIC_PROJECT_TOKEN`.

`ci-website-build.yaml` was left unchanged. Its Ashby and Cloud nodes
integrations intentionally fall back to committed snapshots when secrets
are missing for preview/local builds, so it is not the same class of
fork-secret failure.

## Review Focus

Confirm fork PRs still run the unprivileged validation/build paths,
while same-repo PRs and non-PR events keep the existing deploy or
dispatch behavior.

## Validation PRs

Both validation PRs compare against `main`.

- Fork PR from `shihchi`:
[#13309](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13309)
- Same-repo PR from `origin`:
[#13310](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13310)

| Workflow | Guarded job or step | Fork #13309 | Same-repo #13310 |
| --- | --- | --- | --- |
| CI: Website E2E | `Upload test report` | success  | success  |
| CI: Website E2E | `Deploy report to Cloudflare` | skipped  | success
 |
| CI: Vercel Website Preview | `deploy-preview` | skipped  | success 
|
| Cloud Frontend Build Dispatch | `dispatch` | skipped  | success  |
| CI: Tests Storybook | `chromatic-deployment` | skipped  | success  |

Expected result: fork PRs still keep the useful validation artifact
path, but skip secret-backed deploy and dispatch work. Same-repo PRs
keep the privileged behavior.

## Screenshots (if applicable)

N/A, CI-only.

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Workflow `if` condition changes only; no application code. Same-repo
PR behavior is unchanged when secrets are available.
> 
> **Overview**
> Adds **`github.event.pull_request.head.repo.fork == false`** guards so
fork PRs no longer run steps that need repo secrets or trigger external
deploys.
> 
> **Website E2E** — the Cloudflare Playwright report deploy step now
runs only on non-PR events or same-repo PRs, so fork runs can still pass
tests and upload artifacts without failing on missing `CLOUDFLARE_*`
secrets.
> 
> **Vercel website preview** — the preview deploy job is skipped
entirely for fork PRs (Vercel tokens).
> 
> **Storybook Chromatic** — Chromatic deployment on `version-bump-*` PRs
is limited to non-fork PRs (`CHROMATIC_PROJECT_TOKEN`).
> 
> **Cloud dispatch** — build and cleanup dispatches to the cloud repo
for preview labels no longer run for fork PRs, aligning with the
existing fork-guard comment in those workflows.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
027aabc9e3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:29:47 +00:00
Benjamin Lu
471236e08d feat: track subscription cancellation intent and resubscribe clicks (#13368)
## Summary

Instruments the churn funnel: cancellation intent, attempt, abandonment,
and request failure, plus resubscribe clicks — all client-observed from
existing request/response flows, no watchers or polling added. Covers
both billing paths: the mainline (`/customers/*` + Stripe portal) path
via the "Manage subscription" click, and the workspace path via its
in-app cancel dialog.

## Changes

- **What**:
- New events: `app:subscription_cancel_flow_opened` / `_confirmed` /
`_abandoned` / `_failed` and `app:resubscribe_button_clicked`, via
`trackSubscriptionCancellation(stage, metadata)` and
`trackResubscribeClicked` (registry, PostHog, host sink)
  - All cancellation events carry a `source` discriminator:
- `manage_subscription_button` — the mainline path. Legacy users can
only cancel inside the Stripe billing portal, and in-app UI already
covers plan changes, so this click is the closest observable
cancel-intent signal for ~all production users. Only `flow_opened` fires
here (everything past the click happens in Stripe's UI). Probable, not
certain, intent — the portal also serves card updates/invoices.
- `cancel_plan_menu` — the workspace in-app dialog (allowlist-gated
pilot): `flow_opened` on mount, `confirmed` before the API call (failed
attempts still register), `failed` with the error message, `abandoned`
on "Keep subscription"/close. Successful cancels close via a different
path and never emit `abandoned`.
- Metadata carries `current_tier`, billing `cycle`, and (dialog path)
the `end_date` shown to the user
- Resubscribe clicks tracked at both call sites with `source`:
`pricing_dialog` (`useSubscriptionCheckout`, also carrying the dialog's
`payment_intent_source` from #13363) and `settings_billing_panel`
(`useResubscribe`)
- Not instrumented on purpose: the workspace "Manage billing" button and
the "Invoice history" footer link (portal opens without cancel
connotation)

## Review Focus

- Deliberately **no** client-side "cancel succeeded" event: outcome
truth is server-side. Mainline already has it
(`billing:subscription_deleted` from the Stripe webhook in comfy-api);
the workspace path needs a `subscription_cancelled` billing event type
(separate cloud-repo change). The legacy
`useSubscriptionCancellationWatcher` poller emits an undercounted
`app:monthly_subscription_cancelled`; analysis should prefer the server
event.
- `confirmed` fires before the request; growth can join
`flow_opened`/`confirmed` → server-side cancelled events by user +
timestamp.
2026-07-02 14:51:12 -07:00
Mobeen Abdullah
4cc0402325 revert(website): remove Creative Campus customer stories (#13370) (#13407)
## Summary

Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.

This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.

## Changes

- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.

## Review Focus

- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.

## Verification

- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.

## Next steps

- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
2026-07-03 01:49:47 +05:00
Wei Hai
a2adfe5124 fix(ci): drop unsupported 'range' genhtml ignore-errors category (#13396)
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner

## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
2026-07-02 20:08:47 +00:00
Mobeen Abdullah
49a90d4e2e feat(website): add five Creative Campus customer stories (#13370)
## Summary

Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.

## Changes

- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.

## Review Focus

- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.

## Verification

- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.

## Screenshots (if applicable)

Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
2026-07-03 00:34:20 +05:00
Hunter
d6c582c399 feat(billing): gate consolidated billing behind consolidated_billing_enabled flag (#13359)
## Summary

Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.

## Changes

- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
  - team workspaces disabled → legacy
  - personal workspace + consolidated billing off/missing → legacy
  - personal workspace + consolidated billing on → workspace
  - team workspace → workspace
  - workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.

## Testing

- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.

## Related

Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-07-02 18:34:39 +00:00
254 changed files with 18544 additions and 4738 deletions

View File

@@ -1,86 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -134,6 +134,27 @@ jobs:
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail

View File

@@ -1,154 +0,0 @@
# Runs the custom-node regression suite against a backend that has the manifest
# packs actually installed, so the load/run tiers execute for real. This is a
# GATING check: if a pack fails to install or any tier is skipped, the job goes
# red - a regression gate that let a broken pack through as a "skip" would be
# pointless. Mark `custom-nodes-e2e` as a required status check in branch
# protection to block merges on failure.
name: 'CI: Tests Custom Nodes'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
push:
branches: [main, master]
merge_group:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Path gating lives here, not in a trigger-level `paths:` filter: a required
# check gated by trigger paths never creates a check run on an unrelated PR
# and leaves branch protection stuck Pending. A job-level `if:` still creates
# the check and marks it Skipped (= passing). Mirrors ci-tests-unit.yaml.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
custom-nodes-e2e:
needs: changes
# Run only when non-docs code changed AND the PR is same-repo. Fork PRs can
# edit the manifest's repo/pin URLs, and this job clones and pip-installs
# whatever they point at (setup.py runs at install time), so an untrusted
# fork must not be able to aim the clone at an attacker-controlled repo.
# Fork PRs still get the environment-agnostic coverage via the main e2e
# shards. A skipped job counts as passing, so this stays required-safe.
if: >-
needs.changes.outputs.should-run == 'true' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Checks out ComfyUI, installs Python/torch/requirements and ComfyUI_devtools.
# launch_server:false so we can add the manifest packs before booting.
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
# Install every pack the manifest declares (DRY: a new pack row installs
# itself here, no workflow change). A clone or dependency failure fails the
# job - if a pack can't be installed, its coverage can't run, and that is a
# gate failure, not something to paper over. The `jq | while` pipe hides
# failures in a subshell, so read into an array and loop with `set -e`.
- name: Install manifest custom nodes
shell: bash
run: |
set -euo pipefail
# Pin the CPU torch stack that setup-comfyui-server installed so no
# pack's requirements.txt can pull a GPU/incompatible torch onto this
# --cpu runner. A pack that genuinely needs a different torch fails
# the constrained install loudly rather than silently swapping it.
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' \
> /tmp/torch-constraints.txt || true
manifest=browser_tests/fixtures/data/customNodeManifest.json
mapfile -t entries < <(jq -c '.[]' "$manifest")
for entry in "${entries[@]}"; do
repo=$(jq -r '.repo' <<<"$entry")
pin=$(jq -r '.pin' <<<"$entry")
name=$(basename "$repo")
dir="ComfyUI/custom_nodes/$name"
echo "::group::install $name"
git clone --depth 1 "$repo" "$dir"
if [ -n "$pin" ]; then
git -C "$dir" fetch --depth 1 origin "$pin"
git -C "$dir" checkout "$pin"
fi
if [ -f "$dir/requirements.txt" ]; then
pip install -r "$dir/requirements.txt" -c /tmp/torch-constraints.txt
fi
echo "::endgroup::"
done
# The VHS run-tier workflow reads input/plain_video.mp4.
- name: Stage run-tier assets
shell: bash
run: cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4
# --cache-none so retried run-tier tests re-execute every node (a cached
# node emits no `executing` event and would false-fail PARTIAL).
- name: Start ComfyUI server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --cache-none --front-end-root ../dist &
wait-for-it --service 127.0.0.1:8188 -t 600
- name: Run custom-node suite
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: custom-nodes-results.json
run: |
pnpm exec playwright test browser_tests/tests/customNodes/ \
--project=chromium --reporter=list,json
# A skip here means a pack or devtools did not load: on this backend every
# tier is meant to run, so a skip is a gate failure, not an honest pass.
- name: Forbid skipped tests
if: always()
shell: bash
run: |
set -euo pipefail
skipped=$(jq '.stats.skipped' custom-nodes-results.json)
echo "skipped tests: $skipped"
if [ "$skipped" != "0" ]; then
echo "::error::$skipped test(s) skipped - a manifest pack or devtools failed to load; skips are not acceptable in the gating job"
# Recurse so specs nested under describe() blocks are found, and
# print only the specs that actually skipped.
jq -r '.. | objects
| select(has("title") and has("tests"))
| select(any(.tests[]?; .status == "skipped"))
| .title' custom-nodes-results.json | sort -u | head -40
exit 1
fi
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report-custom-nodes
path: playwright-report/
retention-days: 7
if-no-files-found: warn

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--ignore-errors source,unmapped \
--synthesize-missing
- name: Upload HTML report artifact

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -55,6 +55,3 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">

View File

@@ -33,36 +33,41 @@ useHeroAnimation({
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
>
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<div class="lg:max-w-xl">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<h1
ref="headingRef"
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<div ref="descRef">
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('description'), locale) }}
</p>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
</div>
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">

View File

@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
label: t('nav.products', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.tryWorkflow', locale),
label: t('cta.getStarted', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
href: routes.mcp
}
},
columns: [

View File

@@ -26,6 +26,10 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.getStarted': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
@@ -2196,16 +2200,16 @@ const translations = {
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布:Seedance 2.0'
en: 'NEW: COMFY MCP',
'zh-CN': '全新发布:Comfy MCP'
},
'nav.featuredProductsAlt': {
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
en: 'Comfy MCP feature image',
'zh-CN': 'Comfy MCP 精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',

View File

@@ -123,15 +123,6 @@ Browser tests in this project follow a specific organization pattern:
- **Utilities**: Located in `utils/` - Common utility functions
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
### Custom-node regression suite
`tests/customNodes/` holds the manifest-driven suite that proves community
custom-node packs load, render in both renderers (LiteGraph canvas and Vue
Nodes 2.0), and execute real workflows. It has its own prerequisites, pnpm
scripts (`pnpm test:custom-nodes` and per-pack variants), and a
one-JSON-row process for adding packs - see
[tests/customNodes/README.md](tests/customNodes/README.md).
## Writing Effective Tests
When writing new tests, follow these patterns:

View File

@@ -1,53 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "PrimitiveInt",
"pos": { "0": 20, "1": 60 },
"size": { "0": 250, "1": 80 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [42, "fixed"]
},
{
"id": 2,
"type": "PreviewAny",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
}
],
"links": [[1, 1, 0, 2, 0, "INT"]],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,60 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "StringFunction|pysssss",
"pos": { "0": 20, "1": 60 },
"size": { "0": 300, "1": 240 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "StringFunction|pysssss"
},
"widgets_values": ["append", "yes", "hello", " world", ""]
},
{
"id": 2,
"type": "ShowText|pysssss",
"pos": { "0": 380, "1": 60 },
"size": { "0": 220, "1": 80 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 1
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": null,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ShowText|pysssss"
}
}
],
"links": [[1, 1, 0, 2, 0, "STRING"]],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,61 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "SimpleMathInt+",
"pos": { "0": 20, "1": 60 },
"size": { "0": 250, "1": 60 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "SimpleMathInt+"
},
"widgets_values": [5]
},
{
"id": 2,
"type": "DisplayAny",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 80 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "input",
"type": "*",
"link": 1
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": null,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "DisplayAny"
},
"widgets_values": ["raw value"]
}
],
"links": [[1, 1, 0, 2, 0, "INT"]],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,98 +0,0 @@
{
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "ImpactInt",
"pos": { "0": 20, "1": 60 },
"size": { "0": 250, "1": 60 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImpactInt"
},
"widgets_values": [42]
},
{
"id": 2,
"type": "PreviewAny",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
},
{
"id": 3,
"type": "ImpactFloat",
"pos": { "0": 20, "1": 220 },
"size": { "0": 250, "1": 60 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [2],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImpactFloat"
},
"widgets_values": [3.14]
},
{
"id": 4,
"type": "PreviewAny",
"pos": { "0": 340, "1": 220 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
}
],
"links": [
[1, 1, 0, 2, 0, "INT"],
[2, 3, 0, 4, 0, "FLOAT"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,98 +0,0 @@
{
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "INTConstant",
"pos": { "0": 20, "1": 60 },
"size": { "0": 250, "1": 60 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "value",
"type": "INT",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "INTConstant"
},
"widgets_values": [42]
},
{
"id": 2,
"type": "PreviewAny",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
},
{
"id": 3,
"type": "FloatConstant",
"pos": { "0": 20, "1": 220 },
"size": { "0": 250, "1": 60 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "value",
"type": "FLOAT",
"links": [2],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "FloatConstant"
},
"widgets_values": [3.14]
},
{
"id": 4,
"type": "PreviewAny",
"pos": { "0": 340, "1": 220 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
}
],
"links": [
[1, 1, 0, 2, 0, "INT"],
[2, 3, 0, 4, 0, "FLOAT"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,53 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "Seed (rgthree)",
"pos": { "0": 20, "1": 60 },
"size": { "0": 250, "1": 130 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "SEED",
"type": "INT",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Seed (rgthree)"
},
"widgets_values": [12345]
},
{
"id": 2,
"type": "Display Any (rgthree)",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "Display Any (rgthree)"
}
}
],
"links": [[1, 1, 0, 2, 0, "INT"]],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,107 +0,0 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "VHS_LoadVideoPath",
"pos": { "0": 20, "1": 60 },
"size": { "0": 320, "1": 260 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "frame_count",
"type": "INT",
"links": null
},
{
"name": "audio",
"type": "AUDIO",
"links": null
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": [1],
"slot_index": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideoPath"
},
"widgets_values": ["input/plain_video.mp4", 0, 0, 0, 0, 0, 1]
},
{
"id": 2,
"type": "VHS_VideoInfo",
"pos": { "0": 400, "1": 60 },
"size": { "0": 240, "1": 260 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"link": 1
}
],
"outputs": [
{
"name": "source_fps🟨",
"type": "FLOAT",
"links": [2],
"slot_index": 0
},
{ "name": "source_frame_count🟨", "type": "INT", "links": null },
{ "name": "source_duration🟨", "type": "FLOAT", "links": null },
{ "name": "source_width🟨", "type": "INT", "links": null },
{ "name": "source_height🟨", "type": "INT", "links": null },
{ "name": "loaded_fps🟦", "type": "FLOAT", "links": null },
{ "name": "loaded_frame_count🟦", "type": "INT", "links": null },
{ "name": "loaded_duration🟦", "type": "FLOAT", "links": null },
{ "name": "loaded_width🟦", "type": "INT", "links": null },
{ "name": "loaded_height🟦", "type": "INT", "links": null }
],
"properties": {
"Node name for S&R": "VHS_VideoInfo"
}
},
{
"id": 3,
"type": "PreviewAny",
"pos": { "0": 700, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
}
],
"links": [
[1, 1, 3, 2, 0, "VHS_VIDEOINFO"],
[2, 2, 0, 3, 0, "FLOAT"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -1,103 +0,0 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Constant Number",
"pos": { "0": 20, "1": 60 },
"size": { "0": 250, "1": 100 },
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "NUMBER",
"type": "NUMBER",
"links": [1],
"slot_index": 0
},
{
"name": "FLOAT",
"type": "FLOAT",
"links": null,
"slot_index": 1
},
{
"name": "INT",
"type": "INT",
"links": null,
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "Constant Number"
},
"widgets_values": ["integer", 7]
},
{
"id": 2,
"type": "Number to Text",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "number",
"type": "NUMBER",
"link": 1
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [2],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Number to Text"
}
},
{
"id": 3,
"type": "Text to Console",
"pos": { "0": 640, "1": 60 },
"size": { "0": 250, "1": 80 },
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "text",
"type": "STRING",
"link": 2
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": null,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Text to Console"
},
"widgets_values": ["Text Output"]
}
],
"links": [
[1, 1, 0, 2, 0, "NUMBER"],
[2, 2, 0, 3, 0, "STRING"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -268,16 +268,8 @@ export class ComfyPage {
data: { username }
})
if (resp.status() !== 200) {
const body = await resp.text()
// Persistent backends (Comfy Desktop server user storage) keep the user
// across runs and do not list it via GET /api/users, so a duplicate means
// it already exists. Returns the username since the generated id is not
// retrievable here; only reached on single-user / default-resolving backends.
if (resp.status() === 400 && body.includes('Duplicate username.'))
return username
throw new Error(`Failed to create user: ${body}`)
}
if (resp.status() !== 200)
throw new Error(`Failed to create user: ${await resp.text()}`)
return await resp.json()
}
@@ -545,7 +537,6 @@ export const comfyPageFixture = base.extend<{
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,

View File

@@ -1,141 +0,0 @@
import type { Page } from '@playwright/test'
import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
import type {
ExecutionError,
PromptEvent,
RunResult
} from '@e2e/fixtures/customNode/runResult'
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
interface RawEvent {
type: string
node?: string | null
exception_type?: string
node_id?: string
node_type?: string
traceback?: string[]
}
const TERMINAL = [
'execution_success',
'execution_error',
'execution_interrupted'
]
function toPromptEvent(raw: RawEvent): PromptEvent {
if (raw.type === 'executing')
return { type: 'executing', node: raw.node ?? null }
if (raw.type === 'execution_error' || raw.type === 'execution_interrupted') {
const error: ExecutionError = {
exceptionType: raw.exception_type,
nodeId: raw.node_id,
nodeType: raw.node_type,
traceback: raw.traceback
}
return { type: raw.type, error }
}
return { type: raw.type as 'execution_start' | 'execution_success' }
}
/**
* Drives a real ComfyUI backend through the running frontend. The verdict logic
* lives in the pure `classifyRun`; this class is only the in-page IO plumbing.
*/
export class LocalDesktopTarget {
async getObjectInfo(page: Page): Promise<ObjectInfo> {
return await page.evaluate(async () => {
const defs = await window.app!.api.getNodeDefs()
const out: Record<
string,
{ input?: { required?: Record<string, unknown> } }
> = {}
for (const [name, def] of Object.entries(defs)) {
const required = (
def as { input?: { required?: Record<string, unknown> } }
).input?.required
out[name] = { input: { required } }
}
return out
})
}
async runWorkflow(
page: Page,
opts: { expectedNodeIds: string[]; timeoutMs: number }
): Promise<RunResult> {
await page.evaluate(
(types) => {
const sink = window as unknown as {
__cnEvents: RawEvent[]
__cnTapInstalled?: boolean
}
sink.__cnEvents = []
if (sink.__cnTapInstalled) return
sink.__cnTapInstalled = true
for (const type of types)
(window.app!.api as EventTarget).addEventListener(
type,
(event: Event) => {
const detail: unknown = (event as CustomEvent).detail
// `executing` dispatches a bare node-id string (api.ts
// dispatchCustomEvent('executing', msg.data.node)); the other
// events dispatch object payloads.
sink.__cnEvents.push(
detail !== null && typeof detail === 'object'
? { type, ...(detail as Record<string, unknown>) }
: { type, node: (detail as string | undefined) ?? null }
)
}
)
},
['execution_start', ...TERMINAL, 'executing']
)
// Browser path: app.queuePrompt runs graphToPrompt internally. Do NOT call
// app.api.queuePrompt, which submits an already-serialized (empty) prompt.
// A backend validation reject emits NO events at all - without checking the
// queue result, every rejected prompt would burn the full wait and
// masquerade as TIMEOUT. But a false return is NOT proof of a backend
// reject: pack JS hooking the queue path (Impact's wildcard processing)
// can refuse transiently right after nodes are created. Retry once -
// genuine backend rejects are deterministic and fail both attempts.
let queued = await page.evaluate(() => window.app!.queuePrompt(0))
if (queued === false) {
await page.evaluate(
() => new Promise((resolve) => setTimeout(resolve, 250))
)
queued = await page.evaluate(() => window.app!.queuePrompt(0))
if (queued === false)
return { outcome: 'VALIDATION_FAIL', executedNodes: [] }
}
await page
.waitForFunction(
(terminal) => {
const events =
(window as unknown as { __cnEvents?: { type: string }[] })
.__cnEvents ?? []
return events.some((event) => terminal.includes(event.type))
},
TERMINAL,
{ timeout: opts.timeoutMs }
)
.catch((error: unknown) => {
// Only a Playwright wait timeout means "no terminal event"; surface any
// other fault instead of masquerading it as a run TIMEOUT.
if (error instanceof Error && error.name === 'TimeoutError') return
throw error
})
const raw = await page.evaluate(
() => (window as unknown as { __cnEvents?: RawEvent[] }).__cnEvents ?? []
)
const timedOut = !raw.some((event) => TERMINAL.includes(event.type))
return classifyRun({
events: raw.map(toPromptEvent),
expectedNodeIds: opts.expectedNodeIds,
timedOut
})
}
}

View File

@@ -1,108 +0,0 @@
// Classifies which registered nodes can execute with zero hand-authored
// fixtures: every required input satisfiable by a widget default, plus an
// observable terminus (the node is an OUTPUT_NODE, or an output we can wire
// to core PreviewAny). Those run for real on the backend; the rest are
// recorded with the reason they cannot run alone - never silently dropped.
import type { RawNodeDef } from './typePairing'
export type AutoRunClass =
// Runs alone: widgets cover every required input, terminus available.
| 'AUTO_RUNNABLE'
// A required input is a socket (custom type or forceInput); execution
// needs wiring, which the curated run workflows cover instead.
| 'NEEDS_WIRES'
// A required combo has zero options on this backend - a model/file scan
// came back empty, so no valid default exists (CPU gate has no models).
| 'NEEDS_MODELS'
// Widgets suffice but nothing observable to queue: no outputs and not an
// OUTPUT_NODE, so the executor would never touch it.
| 'NO_SINK'
export interface AutoRunVerdict {
key: string
verdict: AutoRunClass
// Set for AUTO_RUNNABLE: wire output 0 to PreviewAny (false = the node is
// its own OUTPUT_NODE terminus and runs standalone).
needsPreviewSink?: boolean
reason: string
}
const WIDGET_TYPES = new Set(['INT', 'FLOAT', 'STRING', 'BOOLEAN'])
type InputSpec = [unknown, Record<string, unknown>?] | unknown
function classifyInput(
name: string,
spec: InputSpec
): 'widget' | 'socket' | 'empty-combo' {
const specArray = Array.isArray(spec) ? spec : [spec]
const rawType = specArray[0]
const options = specArray[1] as { forceInput?: boolean } | undefined
if (Array.isArray(rawType))
return rawType.length > 0 ? 'widget' : 'empty-combo'
if (typeof rawType !== 'string') return 'socket'
if (options?.forceInput) return 'socket'
return WIDGET_TYPES.has(rawType) ? 'widget' : 'socket'
}
export function classifyAutoRunnable(
key: string,
def: RawNodeDef & { output_node?: boolean }
): AutoRunVerdict {
for (const [name, spec] of Object.entries(def.input?.required ?? {})) {
const kind = classifyInput(name, spec)
if (kind === 'socket')
return {
key,
verdict: 'NEEDS_WIRES',
reason: `required input "${name}" is a socket`
}
if (kind === 'empty-combo')
return {
key,
verdict: 'NEEDS_MODELS',
reason: `required combo "${name}" has no options on this backend`
}
}
if (def.output_node === true)
return {
key,
verdict: 'AUTO_RUNNABLE',
needsPreviewSink: false,
reason: 'widgets satisfy all required inputs; node is its own terminus'
}
if ((def.output ?? []).length > 0)
return {
key,
verdict: 'AUTO_RUNNABLE',
needsPreviewSink: true,
reason: 'widgets satisfy all required inputs; output 0 -> PreviewAny'
}
return {
key,
verdict: 'NO_SINK',
reason: 'no outputs and not an OUTPUT_NODE - nothing observable to queue'
}
}
export function planAutoRuns(
defs: Record<string, RawNodeDef & { output_node?: boolean }>,
packNodeKeys: string[]
): AutoRunVerdict[] {
return packNodeKeys.map((key) => classifyAutoRunnable(key, defs[key]))
}
// Independent single-node chains per prompt; one bad node fails its batch,
// not the whole tier, and the executed-set check still attributes per node.
export function batchAutoRunnable(
verdicts: AutoRunVerdict[],
batchSize: number
): AutoRunVerdict[][] {
const runnable = verdicts.filter(
(verdict) => verdict.verdict === 'AUTO_RUNNABLE'
)
const batches: AutoRunVerdict[][] = []
for (let offset = 0; offset < runnable.length; offset += batchSize)
batches.push(runnable.slice(offset, offset + batchSize))
return batches
}

View File

@@ -1,124 +0,0 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const MANIFEST_PATH = fileURLToPath(
new URL('../data/customNodeManifest.json', import.meta.url)
)
const VALID_TIERS = ['load', 'run', 'connectivity', 'io'] as const
type CustomNodeTier = (typeof VALID_TIERS)[number]
export interface CustomNodeManifestEntry {
pack: string
repo: string
pin: string
tiers: CustomNodeTier[]
// Frontend-format workflow (path relative to browser_tests/) loaded and queued
// by the run/io tiers; empty or absent file = tier skips. Run the backend with
// --cache-none, or repeat runs classify PARTIAL when cached nodes skip executing.
workflow: string
// Runtime class_type / object_info keys, NOT Python class names (e.g. rgthree
// registers "Power Primitive (rgthree)", not RgthreePowerPrimitive).
expectedNodes: string[]
requiresGpu: boolean
requiresModels: string[]
timeoutMs: number
// Optional; absent means true. Set false ONLY with evidence that the pack's
// nodes fail to mount under Vue Nodes 2.0 (probe it - a README grumble is
// not evidence). When false, renderer-specific Vue assertions are not
// applied to this pack: its tests still run and pass their LiteGraph-canvas
// assertions, so the zero-skip gate is preserved.
vueNodesCompatible?: boolean
// Per-node Vue Nodes 2.0 incompatibility ledger: node key -> reason with
// evidence (author statement or reproduced mount failure; a run failure
// alone is NOT evidence - it may be our own fixture error). Ledgered nodes
// keep every canvas assertion; only their Vue mount assertion is withheld.
// A key that stops existing on the backend fails the suite, so entries
// cannot silently rot.
vueIncompatibleNodes?: Record<string, string>
// Auto-run baseline: nodes observed unable to execute standalone on a bare
// backend (validation reject or execution error on pure defaults - empty
// expressions, empty folders, no webcam). Asserted BOTH ways: a failing
// node missing from this list is a regression, and a listed node that now
// runs clean must be removed. Weak-signal territory by design - a wrong
// entry here means a fixture gap, never a skipped test.
cannotRunAlone?: string[]
}
function assertEntry(entry: CustomNodeManifestEntry, index: number): void {
const missing: string[] = []
if (typeof entry.pack !== 'string' || entry.pack.length === 0)
missing.push('pack')
// CI clones from repo, so an empty value must fail here, not mid-clone.
// pin stays optional ("" = default branch head).
if (typeof entry.repo !== 'string' || entry.repo.length === 0)
missing.push('repo')
// workflow may be an empty string until the pack gains a run-tier fixture.
if (typeof entry.workflow !== 'string') missing.push('workflow')
// A run-tier row with no workflow would otherwise skip locally, leaving
// only CI's skip gate to notice the lost coverage. Fail at load instead.
else if (
entry.workflow === '' &&
Array.isArray(entry.tiers) &&
entry.tiers.includes('run')
)
missing.push('workflow (required when tiers includes "run")')
if (!Array.isArray(entry.expectedNodes) || entry.expectedNodes.length === 0)
missing.push('expectedNodes')
if (!Array.isArray(entry.tiers) || entry.tiers.length === 0)
missing.push('tiers')
// A typo like "connectivty" would otherwise pass and silently drop that
// tier's coverage - the exact drift this manifest exists to catch.
else if (entry.tiers.some((tier) => !VALID_TIERS.includes(tier)))
missing.push(`tiers (unknown value; allowed: ${VALID_TIERS.join(', ')})`)
if (!Array.isArray(entry.requiresModels)) missing.push('requiresModels')
if (typeof entry.requiresGpu !== 'boolean') missing.push('requiresGpu')
if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0)
missing.push('timeoutMs')
if (
entry.vueNodesCompatible !== undefined &&
typeof entry.vueNodesCompatible !== 'boolean'
)
missing.push('vueNodesCompatible')
if (
entry.vueIncompatibleNodes !== undefined &&
(typeof entry.vueIncompatibleNodes !== 'object' ||
entry.vueIncompatibleNodes === null ||
Array.isArray(entry.vueIncompatibleNodes) ||
Object.values(entry.vueIncompatibleNodes).some(
(reason) => typeof reason !== 'string' || reason.length === 0
))
)
missing.push('vueIncompatibleNodes (node key -> non-empty reason string)')
if (
entry.cannotRunAlone !== undefined &&
(!Array.isArray(entry.cannotRunAlone) ||
entry.cannotRunAlone.some(
(key) => typeof key !== 'string' || key.length === 0
) ||
new Set(entry.cannotRunAlone).size !== entry.cannotRunAlone.length)
)
missing.push('cannotRunAlone (unique non-empty node keys)')
if (missing.length > 0)
throw new Error(
`custom-node manifest entry ${index} (${entry.pack ?? '?'}) missing: ${missing.join(', ')}`
)
}
// Renderer passes for the load tier: LiteGraph canvas always, Vue Nodes 2.0
// unless the pack declares itself incompatible. Conditional coverage, never a
// test.skip - the caller still runs and gates on the returned passes.
export function rendererPassesFor(
entry: Pick<CustomNodeManifestEntry, 'vueNodesCompatible'>
): boolean[] {
return entry.vueNodesCompatible === false ? [false] : [false, true]
}
export function loadManifest(): CustomNodeManifestEntry[] {
const entries = JSON.parse(
readFileSync(MANIFEST_PATH, 'utf-8')
) as CustomNodeManifestEntry[]
entries.forEach(assertEntry)
return entries
}

View File

@@ -1,54 +0,0 @@
import type { CustomNodeOutcome } from '@e2e/fixtures/customNode/runResult'
interface ObjectInfoNode {
input?: { required?: Record<string, unknown> }
}
export type ObjectInfo = Record<string, ObjectInfoNode>
export interface ApiPromptNode {
id: string
classType: string
inputs: Record<string, unknown>
}
export function expectedNodesPresent(
objectInfo: ObjectInfo,
expectedNodes: string[]
): { present: string[]; missing: string[] } {
const present: string[] = []
const missing: string[] = []
for (const name of expectedNodes) {
if (name in objectInfo) present.push(name)
else missing.push(name)
}
return { present, missing }
}
export interface PreValidationFailure {
outcome: Extract<CustomNodeOutcome, 'MISSING_NODE' | 'VALIDATION_FAIL'>
message: string
}
// Turns an opaque backend 400 into a precise infra error before submit (BE-401):
// every required input declared in object_info must be present in the fixture node.
export function preValidate(
objectInfo: ObjectInfo,
nodes: ApiPromptNode[]
): PreValidationFailure | null {
for (const node of nodes) {
const def = objectInfo[node.classType]
if (!def)
return {
outcome: 'MISSING_NODE',
message: `node ${node.id} ${node.classType} missing from object_info`
}
for (const name of Object.keys(def.input?.required ?? {})) {
if (!(name in node.inputs))
return {
outcome: 'VALIDATION_FAIL',
message: `node ${node.id} ${node.classType} missing required input "${name}"`
}
}
}
return null
}

View File

@@ -1,72 +0,0 @@
export type CustomNodeOutcome =
| 'NOT_INSTALLED'
| 'IMPORT_ERROR'
| 'MISSING_NODE'
| 'VALIDATION_FAIL'
| 'EXECUTION_ERROR'
| 'PARTIAL'
| 'TIMEOUT'
| 'PASS'
export interface ExecutionError {
exceptionType?: string
nodeId?: string
nodeType?: string
traceback?: string[]
}
export type PromptEvent =
| { type: 'execution_start' }
| { type: 'executing'; node: string | null }
| { type: 'execution_success' }
| { type: 'execution_error'; error: ExecutionError }
| { type: 'execution_interrupted'; error?: ExecutionError }
export interface RunResult {
outcome: CustomNodeOutcome
executedNodes: string[]
error?: ExecutionError
}
// `executing` with a non-null node is the only cache-safe "this node actually ran"
// signal: ComfyUI emits it solely for non-cached nodes (execution.py:493), while the
// `executed` message and /history outputs are replayed for cached nodes too.
function executedNodesFrom(events: PromptEvent[]): string[] {
const executed = new Set<string>()
for (const event of events) {
if (event.type === 'executing' && event.node !== null)
executed.add(event.node)
}
return [...executed]
}
export function classifyRun(input: {
events: PromptEvent[]
expectedNodeIds: string[]
timedOut?: boolean
}): RunResult {
const { events, expectedNodeIds, timedOut = false } = input
const executedNodes = executedNodesFrom(events)
if (timedOut) return { outcome: 'TIMEOUT', executedNodes }
const failure = events.find(
(
event
): event is Extract<
PromptEvent,
{ type: 'execution_error' | 'execution_interrupted' }
> =>
event.type === 'execution_error' || event.type === 'execution_interrupted'
)
if (failure)
return { outcome: 'EXECUTION_ERROR', executedNodes, error: failure.error }
if (!events.some((event) => event.type === 'execution_success'))
return { outcome: 'TIMEOUT', executedNodes }
const ranEveryExpected = expectedNodeIds.every((node) =>
executedNodes.includes(node)
)
return { outcome: ranEveryExpected ? 'PASS' : 'PARTIAL', executedNodes }
}

View File

@@ -1,216 +0,0 @@
// Type-driven pairing generator for the connectivity (contract) tier.
// Wildcard `*` slots are excluded from pairing: LiteGraph.isValidConnection
// short-circuits on `*` before the real type compare, so a wildcard link
// proves reachability, not type interop.
export interface RawNodeDef {
input?: {
required?: Record<string, unknown>
optional?: Record<string, unknown>
}
output?: unknown[]
output_name?: string[]
python_module?: string
}
interface NormalizedSlot {
name: string
type: string
}
export interface NormalizedNode {
type: string
pack: string
inputs: NormalizedSlot[]
outputs: NormalizedSlot[]
}
interface SlotRef {
nodeType: string
pack: string
slotName: string
slotType: string
}
export interface PlannedPair {
producer: SlotRef
consumer: SlotRef
}
export interface PairingPlan {
pairs: PlannedPair[]
// No compatible partner in the loaded corpus: a health signal, not a failure.
orphans: Array<SlotRef & { dir: 'in' | 'out' }>
// `*` / empty-typed slots, excluded by design (false confidence).
wildcards: Array<SlotRef & { dir: 'in' | 'out' }>
// COMBO-literal slots, excluded by design: isValidConnection only compares
// the string COMBO while each slot carries its own option set, so a
// type-level pairing proves nothing (a checkpoint dropdown would "connect"
// to a scheduler dropdown). Targeted fixtures cover combo behavior.
combos: Array<SlotRef & { dir: 'in' | 'out' }>
}
// Extends the shared outcome taxonomy (runResult.ts); ORPHAN_TYPE is a
// plan-time skip so it never reaches the executor.
// WIDGET_ONLY_ON_INSTANCE: the pack's own frontend JS rebuilt a declared
// input as a widget-only control, so there is no socket to wire - excluded
// like wildcards, never a failure and never a silent pass.
export type ConnectivityOutcome =
| 'PASS'
| 'CONNECT_REJECTED'
| 'ROUNDTRIP_LOST'
| 'SLOT_CONTRACT_MISMATCH'
| 'WIDGET_ONLY_ON_INSTANCE'
export function packOf(pythonModule: string | undefined): string {
if (pythonModule?.startsWith('custom_nodes.'))
return pythonModule.slice('custom_nodes.'.length)
return 'core'
}
export function isWildcard(type: string): boolean {
return type === '' || type === '*'
}
// COMBO list literals are arrays; their connectable socket type is COMBO.
function slotTypeOf(rawType: unknown): string | null {
if (Array.isArray(rawType)) return 'COMBO'
return typeof rawType === 'string' ? rawType : null
}
function inputSlots(
entries: Record<string, unknown> | undefined
): NormalizedSlot[] {
if (!entries) return []
const slots: NormalizedSlot[] = []
for (const [name, spec] of Object.entries(entries)) {
const specArray = Array.isArray(spec) ? spec : [spec]
const type = slotTypeOf(specArray[0])
if (type === null) continue
const opts = specArray[1] as { socketless?: boolean } | undefined
// socketless = widget only, no slot: not connectable, out of the matrix.
if (opts?.socketless) continue
slots.push({ name, type })
}
return slots
}
export function normalizeNodeDefs(
defs: Record<string, RawNodeDef>
): NormalizedNode[] {
return Object.entries(defs).map(([type, def]) => ({
type,
pack: packOf(def.python_module),
inputs: [
...inputSlots(def.input?.required),
...inputSlots(def.input?.optional)
],
outputs: (def.output ?? []).flatMap((rawType, index) => {
const slotType = slotTypeOf(rawType)
if (slotType === null) return []
// output_name entries can be non-strings (COMBO literals repeat the
// option array); the slot name must stay a string.
const rawName = def.output_name?.[index]
return [
{
name: typeof rawName === 'string' ? rawName : slotType,
type: slotType
}
]
})
}))
}
// Faithful mirror of LiteGraph.isValidConnection (LiteGraphGlobal.ts):
// wildcard/empty always match, comparison is case-insensitive, comma-unions
// match if any member pair matches. The live sweep still connects through the
// REAL validator, so any drift here surfaces as CONNECT_REJECTED, not a
// silent false green.
export function isTypeCompatible(a: string, b: string): boolean {
if (isWildcard(a) || isWildcard(b)) return true
const typeA = a.toLowerCase()
const typeB = b.toLowerCase()
if (typeA === typeB) return true
if (!typeA.includes(',') && !typeB.includes(',')) return false
return typeA
.split(',')
.some((memberA) =>
typeB.split(',').some((memberB) => isTypeCompatible(memberA, memberB))
)
}
function slotRef(node: NormalizedNode, slot: NormalizedSlot): SlotRef {
return {
nodeType: node.type,
pack: node.pack,
slotName: slot.name,
slotType: slot.type
}
}
// One representative compatible edge per slot, deterministically the first
// partner in (nodeType, slotName) order. This bounds cost to O(slots) but
// does NOT prove every pair; a full cross-product is an opt-in deep mode.
export function planPairs(
all: NormalizedNode[],
corpusTypes: string[]
): PairingPlan {
const sorted = [...all].sort((a, b) => a.type.localeCompare(b.type))
const pairable = (slot: NormalizedSlot) =>
!isWildcard(slot.type) && slot.type !== 'COMBO'
const producers: Array<SlotRef> = sorted.flatMap((node) =>
node.outputs.filter(pairable).map((slot) => slotRef(node, slot))
)
const consumers: Array<SlotRef> = sorted.flatMap((node) =>
node.inputs.filter(pairable).map((slot) => slotRef(node, slot))
)
const plan: PairingPlan = {
pairs: [],
orphans: [],
wildcards: [],
combos: []
}
const seen = new Set<string>()
const addPair = (producer: SlotRef, consumer: SlotRef) => {
const key = `${producer.nodeType}.${producer.slotName}->${consumer.nodeType}.${consumer.slotName}`
if (seen.has(key)) return
seen.add(key)
plan.pairs.push({ producer, consumer })
}
const corpus = all.filter((node) => corpusTypes.includes(node.type))
for (const node of corpus) {
for (const slot of node.inputs) {
if (isWildcard(slot.type)) {
plan.wildcards.push({ ...slotRef(node, slot), dir: 'in' })
continue
}
if (slot.type === 'COMBO') {
plan.combos.push({ ...slotRef(node, slot), dir: 'in' })
continue
}
const producer = producers.find((candidate) =>
isTypeCompatible(candidate.slotType, slot.type)
)
if (producer) addPair(producer, slotRef(node, slot))
else plan.orphans.push({ ...slotRef(node, slot), dir: 'in' })
}
for (const slot of node.outputs) {
if (isWildcard(slot.type)) {
plan.wildcards.push({ ...slotRef(node, slot), dir: 'out' })
continue
}
if (slot.type === 'COMBO') {
plan.combos.push({ ...slotRef(node, slot), dir: 'out' })
continue
}
const consumer = consumers.find((candidate) =>
isTypeCompatible(slot.type, candidate.slotType)
)
if (consumer) addPair(slotRef(node, slot), consumer)
else plan.orphans.push({ ...slotRef(node, slot), dir: 'out' })
}
}
return plan
}

View File

@@ -1,132 +0,0 @@
[
{
"pack": "ComfyUI-Impact-Pack",
"repo": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/impact_primitives_run.json",
"expectedNodes": ["ImpactInt", "ImpactFloat"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"CLIPSegDetectorProvider",
"ImpactMakeImageBatch",
"ImpactMakeMaskBatch",
"MasksToMaskList",
"NoiseInjectionDetailerHookProvider"
]
},
{
"pack": "ComfyUI-VideoHelperSuite",
"repo": "https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/vhs_video_pipeline_run.json",
"expectedNodes": ["VHS_LoadVideoPath", "VHS_VideoInfo"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 90000,
"cannotRunAlone": [
"VHS_LoadAudio",
"VHS_LoadImagePath",
"VHS_LoadImages",
"VHS_LoadImagesPath",
"VHS_LoadVideoFFmpegPath",
"VHS_LoadVideoPath",
"VHS_SelectLatest"
]
},
{
"pack": "rgthree-comfy",
"repo": "https://github.com/rgthree/rgthree-comfy",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/rgthree_seed_display_run.json",
"expectedNodes": ["Seed (rgthree)", "Display Any (rgthree)"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": ["Image or Latent Size (rgthree)"]
},
{
"pack": "ComfyUI_essentials",
"repo": "https://github.com/cubiq/ComfyUI_essentials",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/essentials_math_display_run.json",
"expectedNodes": ["SimpleMathInt+", "DisplayAny"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"MaskFromList+",
"SimpleMath+",
"SimpleMathDual+",
"SimpleMathFloat+",
"SimpleMathInt+",
"SimpleMathPercent+",
"SimpleMathSlider+",
"SimpleMathSliderLowRes+"
]
},
{
"pack": "ComfyUI-KJNodes",
"repo": "https://github.com/kijai/ComfyUI-KJNodes",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/kjnodes_constants_run.json",
"expectedNodes": ["INTConstant", "FloatConstant"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"CameraPoseVisualizer",
"CreateAudioMask",
"CreateMagicMask",
"CreateVoronoiMask",
"GenerateNoise",
"ImageAndMaskPreview",
"LoadImagesFromFolderKJ",
"LoadVideosFromFolder",
"MaskOrImageToWeight",
"VisualizeCUDAMemoryHistory",
"WebcamCaptureCV2",
"WidgetToString"
]
},
{
"pack": "ComfyUI-Custom-Scripts",
"repo": "https://github.com/pythongosssss/ComfyUI-Custom-Scripts",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/customscripts_string_show_run.json",
"expectedNodes": ["StringFunction|pysssss", "ShowText|pysssss"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": ["LoadText|pysssss", "MathExpression|pysssss"]
},
{
"pack": "was-node-suite-comfyui",
"repo": "https://github.com/WASasquatch/was-node-suite-comfyui",
"pin": "",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/was_number_text_run.json",
"expectedNodes": ["Constant Number", "Number to Text", "Text to Console"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"Bus Node",
"Diffusers Hub Model Down-Loader",
"Image Aspect Ratio",
"Image Batch",
"Latent Batch",
"Mask Batch",
"Mask Rect Area",
"Number Counter",
"Random Number"
]
}
]

View File

@@ -1,17 +0,0 @@
import type { ConsoleMessage, Page } from '@playwright/test'
export function collectConsoleErrors(page: Page): {
errors: string[]
stop: () => void
} {
const errors: string[] = []
const listener = (message: ConsoleMessage) => {
if (message.type() !== 'error') return
// Resource errors ("Failed to load resource: 404") are useless without
// the URL, which lives in the message location, not the text.
const url = message.location().url
errors.push(url ? `${message.text()} [${url}]` : message.text())
}
page.on('console', listener)
return { errors, stop: () => page.off('console', listener) }
}

View File

@@ -1,27 +0,0 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Boot every session with a blank graph (loadBlankWorkflow) instead of the
// bundled default template, whose model references error on a model-less
// harness backend and would trip the zero-visible-errors invariant. The
// backend must run --multi-user (the repo-wide prerequisite for browser
// tests): the fixture then writes these settings to the same per-worker
// user the session reads, on CI and locally alike.
// The shared fixture disables the errors tab to hide missing-model
// indicators in unrelated suites; this suite exists to SEE errors, so every
// error surface stays live.
export const customNodeSuiteSettings = {
'Comfy.TutorialCompleted': false,
'Comfy.RightSidePanel.ShowErrorsTab': true
}
// The tutorial path auto-opens the templates browser over the blank graph.
// Dismiss it deterministically so no window ever shows unexpected UI.
export async function dismissTemplatesDialog(
comfyPage: ComfyPage
): Promise<void> {
const templates = comfyPage.page.getByTestId(TestIds.templates.content)
await templates.waitFor({ state: 'visible' })
await comfyPage.page.keyboard.press('Escape')
await templates.waitFor({ state: 'hidden' })
}

View File

@@ -1,16 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
// The app's user-visible error surfaces. A regression run is green only if a
// human looking at the screen would see zero errors - not merely a clean
// console. The harness self-check asserts the overlay IS visible after a
// forced execution error, so these selectors are permanently proven live.
export function errorSurfaces(page: Page): Record<string, Locator> {
return {
errorOverlay: page.getByTestId(TestIds.dialogs.errorOverlay),
errorDialog: page.getByTestId(TestIds.dialogs.errorDialog),
nodeRenderErrors: page.locator('.node-error'),
errorToasts: page.locator('.p-toast-message-error')
}
}

View File

@@ -119,14 +119,6 @@ class NodeSlotReference {
const rawPos = node.getConnectionPos(type === 'input', index)
const convertedPos =
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
// convertOffsetToCanvas is canvas-relative; page.mouse needs page
// coordinates. Identical when the canvas sits at (0,0), but custom-node
// JS can inject page chrome above it (e.g. rgthree's progress bar
// shifts the canvas 16px down), which silently turned every slot drag
// into a title-bar node drag.
const rect = window.app!.canvas.canvas.getBoundingClientRect()
convertedPos[0] += rect.left
convertedPos[1] += rect.top
// Debug logging - convert Float64Arrays to regular arrays for visibility
console.warn(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -1,332 +0,0 @@
# Adding a custom-node pack to the regression suite
The authoritative, step-by-step process for onboarding a new pack. Written to
be followable by a human or an agent with no prior context. The suite itself
(what it asserts, how to run it) is documented in [README.md](README.md);
this file is only about adding coverage for a new pack.
The short version: install the pack on a local test backend, read the pack's
real node keys out of `/object_info`, author one small model-free workflow,
add one row to the manifest, prove it green locally, push. No new test code
is ever needed - the specs iterate the manifest.
## What a manifest row buys you (the tiers)
Adding the one row enrolls the pack in two kinds of coverage:
- **Every-node tiers (automatic, zero configuration).** The suite reads the
pack's FULL node list from the live backend and, for every registered
node: mounts it in both renderers, round-trips it through save/reload,
plans typed connections for all its concrete slots, and executes it for
real when it is self-sufficient (every required input is a widget with a
valid default; output wired to `PreviewAny` or the node is its own
terminus). Nodes that cannot run alone are classified and logged, never
silently dropped: `NEEDS_WIRES` (required socket inputs), `NEEDS_MODELS`
(empty model/file combo on the bare backend), `NO_SINK` (nothing
observable to queue), or "rejected at validation on defaults" (needs a
curated fixture).
- **Curated tiers (the row's fields).** `expectedNodes` + `workflow` drive
the hand-authored run-tier chain (Step 4) proving a real multi-node
wiring executes end to end, and serve as must-exist sentinels.
Every-node coverage means a pack update is tested the moment CI installs
it - including nodes you never listed.
## Step 0 - prerequisites
- A local test backend and dev server set up exactly per the
[README prerequisites](README.md#prerequisites). Do not skip `--multi-user`
or `--cache-none`.
- The pack's GitHub URL. The CI job clones and pip-installs it, so the repo
must be public and its `requirements.txt` must install on a CPU-only
runner. Packs that hard-require CUDA at import time cannot be onboarded
until they guard that import.
## Step 1 - install the pack on the test backend
```bash
cd <test-backend>/custom_nodes
git clone https://github.com/<owner>/<pack>
pip install -r <pack>/requirements.txt # if the pack has one
```
If you run a CPU-only backend, constrain pip so the pack cannot swap in a
different torch (CI does the same):
```bash
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' > /tmp/torch-constraints.txt
pip install -r <pack>/requirements.txt -c /tmp/torch-constraints.txt
```
Restart the backend and check its log: the `Import times for custom nodes`
block must list the pack with no `IMPORT FAILED` marker. An import failure is
a pack bug or a missing dependency - fix that first; nothing downstream can
work without a clean import.
While you are here, note whether the pack ships frontend JS:
```bash
curl -s http://127.0.0.1:8288/extensions | python3 -c '
import json, sys
print(sum(1 for p in json.load(sys.stdin) if p.startswith("/extensions/<pack-dir-name>/")))
'
```
Non-zero means the pack patches the frontend at runtime (restyled nodes,
rebuilt widgets, injected page chrome). Write that down - it decides whether
Step 6 needs the CI-parity run. Both "green locally, red on CI" failures in
the first 5-pack onboarding came from exactly this.
## Step 2 - read the pack's real node keys
The manifest's `expectedNodes` are the pack's `object_info` keys (the same
strings the API uses as `class_type`). They are NOT Python class names and
NOT display names. Get them from the running backend:
```bash
curl -s http://127.0.0.1:8288/object_info | python3 -c '
import json, sys
d = json.load(sys.stdin)
for key, node in sorted(d.items()):
if node.get("python_module") == "custom_nodes.<pack-dir-name>":
print(key)
'
```
Real traps this step catches (each one shipped in a real pack):
| Pack | Correct key | Wrong guesses that look right |
| ---------------------- | ------------------- | ------------------------------------------------------------------------------- |
| ComfyUI_essentials | `SimpleMathInt+` | `SimpleMathInt` (keys carry a trailing `+`, except `DisplayAny` which has none) |
| ComfyUI-KJNodes | `INTConstant` | `INT Constant` (that is the display name) |
| ComfyUI-Custom-Scripts | `ShowText\|pysssss` | `ShowText` (keys carry a `\|pysssss` suffix) |
| rgthree-comfy | `Seed (rgthree)` | `RgthreeSeed` (the Python class name) |
## Step 3 - pick the expected nodes
Choose 2-3 nodes that are:
- **Model-free**: no checkpoint / VAE / CLIP inputs, no file downloads. The
gate runs on CPU with no models installed. Constants, math, text, and
display nodes are ideal.
- **Wireable into a chain**: at least one producer (has a typed output) and
one terminal node. A terminal node either has `output_node: true` in
`/object_info` (it terminates a workflow by itself) or you end the chain in
the core `PreviewAny` node, which accepts any type.
Check a candidate's inputs, outputs, and `output_node` flag:
```bash
curl -s http://127.0.0.1:8288/object_info | python3 -c '
import json, sys
node = json.load(sys.stdin)["<exact key>"]
print(json.dumps({k: node[k] for k in ("input", "output", "output_name", "output_node")}, indent=1))
'
```
Every node you list in `expectedNodes` must appear in the run workflow: the
run tier asserts each one actually executes on the backend.
## Step 4 - author the run-tier workflow
Add one JSON file under `browser_tests/assets/customNodes/`, named
`<pack>_<what it does>_run.json`. Copy an existing asset as the template
(`rgthree_seed_display_run.json` is the simplest two-node example;
`was_number_text_run.json` shows a 3-node chain). It is the frontend
workflow format, hand-authorable:
- `nodes[].type` is the exact `object_info` key from Step 2.
- `widgets_values` is an array in the node's widget order: the `input`
entries from `/object_info` in declaration order (`required` first, then
`optional`), keeping only widget-type inputs (INT, FLOAT, STRING, BOOLEAN,
and combo lists) and skipping any input whose options say
`"forceInput": true` (those are sockets, never widgets). A required input
that is neither a widget type nor `forceInput` (a custom type like
`NUMBER`) is also a socket: wire a link into it or the run fails on a
missing required input.
- A link is one row in `links`: `[link_id, from_node_id, from_slot,
to_node_id, to_slot, "TYPE"]`, plus the matching `link`/`links` ids on the
two nodes' `inputs`/`outputs` entries.
- To wire INTO an input that would normally be a widget (no `forceInput`),
the input entry also needs a `"widget": { "name": "<input name>" }` key -
see `browser_tests/assets/vueNodes/linked-int-widget.json`.
- Keep it tiny. Two to four nodes proving "this pack executes" is the whole
job; feature-depth testing belongs to the pack's own repo.
- If the workflow needs a media file, reuse something already under
`browser_tests/assets/` (e.g. `plain_video.mp4`) - never commit new binary
assets. CI stages `plain_video.mp4` into the backend's `input/` dir; if
your workflow needs a different existing asset staged, extend the
`Stage run-tier assets` step in
`.github/workflows/ci-tests-custom-nodes.yaml`.
- A media path in the workflow (e.g. `input/plain_video.mp4`) resolves
against the backend process's working directory, not the repo. Locally,
copy the file into the `input/` dir of the directory you launched
`main.py` from, or the run tier fails validation with
`Invalid file path` and the test reports `TIMEOUT`.
## Step 5 - add the manifest row
Append one object to `browser_tests/fixtures/data/customNodeManifest.json`:
| Field | Meaning |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pack` | The pack's directory name under `custom_nodes/` (what `git clone` creates). |
| `repo` | The GitHub URL CI clones. Required non-empty. |
| `pin` | Commit SHA or tag CI checks out after cloning; `""` = default branch head. Pin when a pack breaks often; `""` also means new upstream regressions surface here first. |
| `tiers` | Which tiers run: `load` (registers + renders in both renderers), `connectivity` (typed links + slot drags), `run` (executes the workflow). Use all three unless a tier is impossible for the pack. |
| `workflow` | Path relative to `browser_tests/` of the Step 4 file. `""` only while the pack has no `run` tier. |
| `expectedNodes` | The Step 2/3 keys. The load tier mounts each in both renderers; the run tier asserts each executes. |
| `requiresGpu` | `true` only if execution genuinely needs CUDA. Such packs cannot use the `run` tier on the CPU gate. |
| `requiresModels` | Model files the workflow needs (`[]` for the packs onboarded so far - keep it that way whenever possible). |
| `timeoutMs` | Per-test budget. `30000` unless the workflow does real work (video decode uses `90000`). |
| `vueNodesCompatible` | Optional, default `true`. See the policy below. Only ever set `false`, and only with evidence. |
`loadManifest()` (`browser_tests/fixtures/customNode/manifest.ts`) validates
every row and fails loudly on a missing field, an empty `repo`, a misspelled
tier, or a `run` tier with an empty `workflow`.
## Step 6 - prove it green locally, in both environments
### 6a - fast loop (dev server)
```bash
pnpm test:custom-nodes
```
Green means: every tier for every pack passes, zero skips, and the suite's
zero-visible-errors invariant held (no error overlay, dialog, node error, or
error toast at any point). Iterate here - it is the fastest loop.
### 6b - CI-parity run (required if the pack ships frontend JS)
The dev server never loads pack frontend JS (its `/extensions` list is
core-only), so 6a exercises vanilla nodes. If Step 1 found frontend JS, a
6a green proves nothing about the pack's real runtime behavior. CI serves
the built frontend from the backend, so reproduce that exactly:
```bash
pnpm build
# relaunch the test backend with the same flags plus:
# --front-end-root <repo>/dist
# and make sure any run-tier media is in that process's input/ dir
PLAYWRIGHT_TEST_URL=http://127.0.0.1:8288 pnpm exec playwright test \
browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1
```
Both real failures during the first 5-pack onboarding only existed here:
rgthree's progress bar shifted the canvas and broke slot-drag coordinates,
and rgthree's Seed rebuilt a declared input as widget-only. Skipping 6b
means discovering that class of problem one CI round at a time.
### Failure classes and what they mean
- **T0 fails only in the Vue Nodes pass** (the LiteGraph pass is green):
suspected Vue Nodes 2.0 incompatibility. Follow the policy below - do not
delete the pack, do not skip the test.
- **Run tier fails with `PARTIAL`** (some expected nodes never executed):
either the backend is missing `--cache-none` (cached nodes emit no
`executing` event) or an expected node is not actually in the workflow.
- **Run tier fails with an execution error**: the workflow JSON is wrong
(bad key, wrong `widgets_values` order, type-mismatched link) or the pack
cannot execute model-free. Fix the workflow or drop the node for a
simpler one.
- **Connectivity reports zero planned pairs**: the pack's slots are all
wildcard or combo typed (both are excluded from pairing by design because
they bypass the real type compare). The pack still gets load/run coverage.
- **Connectivity logs `widget-only on instance` exclusions**: the pack's own
frontend JS rebuilt a declared input as a widget-only control (rgthree's
Seed does this to `seed`), so there is no socket to wire. Recorded and
excluded, like wildcards - pack design, not a regression.
- **Auto-run reports a node "not in cannotRunAlone"**: the node failed to
execute on pure defaults (validation reject, or a real exception from
degenerate defaults - empty expression, empty folder, no webcam). If the
node USED to run clean this is a regression; otherwise add it to the
row's `cannotRunAlone` baseline with the run log in the PR. The check is
two-way: a listed node that starts running clean fails the suite until
the stale entry is removed.
- **Auto-run fails with `HUNG_BACKEND`**: a node blocked forever during
execution (the canonical case downloads a model at runtime and hangs
without network). The failure names the suspects and the remedy: add the
offender to `AUTO_RUN_EXCLUDE` in `allNodes.spec.ts` with its mechanism,
and restart the test backend (the hang is non-interruptible).
- **Mount test fails on console errors**: a pack's JS logged real errors
while its nodes mounted. If it is pack-attributed noise with no visible
error surface (KJNodes' loader previews fetching `filename=undefined`),
add a scoped `CONSOLE_ERROR_ALLOWLIST` entry with the mechanism;
otherwise it is a finding.
### The exception ledgers (all reasons on the record)
Every escape hatch is a reviewed list whose entries carry the mechanism, so
the gate stays honest and none can grow silently:
| Ledger | Lives in | Covers |
| ---------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
| `vueIncompatibleNodes` | manifest row | node cannot mount under Vue Nodes 2.0 (evidence rule below) |
| `cannotRunAlone` | manifest row | node cannot execute standalone on a bare backend; asserted both ways so entries cannot rot |
| `AUTO_RUN_EXCLUDE` | `allNodes.spec.ts` | executing the node is unsafe on a bare backend (runtime downloads, hangs) |
| `CONSOLE_ERROR_ALLOWLIST` | `allNodes.spec.ts` | pack-attributed console noise with no visible error surface |
| `CONNECT_REJECTED_ALLOWLIST` | `connectivity.spec.ts` | pack JS legitimately vetoes a planned wiring |
| `ROUNDTRIP_LOST_ALLOWLIST` | `connectivity.spec.ts` | pack's own serialize/configure drops links it manages itself |
## Step 7 - push and watch CI
The `CI: Tests Custom Nodes` job (gating) re-does Steps 1-6 from scratch on
every PR: clones every manifest `repo` at its `pin`, pip-installs under CPU
torch constraints, boots the backend, runs the suite, and fails on any
install error, any test failure, or any skipped test. A new pack row is
automatically picked up; no workflow edit is needed unless you must stage an
extra asset (Step 4).
If CI goes red where local was green, reproduce under the Step 6b
environment before changing anything - the first such failure looked like
upstream drift but was actually pack frontend JS that never loads under
the dev server. Only after 6b reproduces it, decide: adjust the suite's
expectation honestly (the way widget-only instance slots became a recorded
exclusion) or, for genuine upstream drift (`pin: ""` tracks the pack's
default branch head), pin the pack to its last good commit. Never paper
over it with a skip.
## Vue Nodes 2.0 compatibility policy
Some packs only work under the LiteGraph canvas renderer and fail to mount
under Vue Nodes 2.0. The suite must state that fact without producing false
failures and without skipping tests:
1. **Default**: every pack is assumed compatible. New rows omit
`vueNodesCompatible`.
2. **Evidence rule**: set `"vueNodesCompatible": false` ONLY after the T0
Vue pass fails for the pack locally while the LiteGraph pass is green,
and the failure reproduces on a retry. A README grumble, a hunch, or an
old forum thread is not evidence. Record the evidence (the failing
assertion and the pack version) in the PR description of the change that
sets the flag. When only SOME of a pack's nodes fail to mount, use the
per-node `vueIncompatibleNodes` ledger in the manifest row instead of
flagging the whole pack - compatibility is per-node, not per-pack (all
823 nodes across the first 7 packs mount clean, so both mechanisms ship
unused; the every-node mount tier is what earns an entry).
3. **Effect of `false`**: the load tier runs its LiteGraph pass only, and
the connectivity drag test does not drag that pack's edges under Vue
Nodes. The tests still run and pass their canvas assertions - nothing is
`test.skip`ped, so the CI skip gate stays honest. The run tier and the
connectivity contract sweep are renderer-independent (they never toggle
the Vue Nodes setting) and run for the pack regardless of the flag - a
flagged pack must still execute and wire cleanly there.
4. **Un-flagging**: if a pack ships Vue Nodes support later, delete the flag
and prove T0 green in both passes locally.
## Checklist
- [ ] Pack installs clean on the test backend (no `IMPORT FAILED`)
- [ ] Checked whether the pack ships frontend JS (Step 1 `/extensions` probe)
- [ ] `expectedNodes` copied exactly from `/object_info` (Step 2 traps checked)
- [ ] All expected nodes are model-free and present in the run workflow
- [ ] Workflow JSON under `browser_tests/assets/customNodes/`, no new binaries
- [ ] Any media staged into the backend's own `input/` dir locally (Step 4)
- [ ] Manifest row appended with every field (Step 5 table)
- [ ] `vueNodesCompatible` omitted, or set `false` with recorded evidence
- [ ] 6a green: `pnpm test:custom-nodes` against the dev server, zero skips
- [ ] 6b green when the pack ships frontend JS: built dist + backend-served run
- [ ] Every-node tiers green: no unexplained mount/save-reload/auto-run
failures; any new ledger entry carries its mechanism
- [ ] Pushed; `CI: Tests Custom Nodes` green on the PR

View File

@@ -1,115 +0,0 @@
# Custom-node regression suite
Proves community custom-node packs work against this frontend across both
renderers: nodes register, render under LiteGraph (canvas) AND Vue Nodes 2.0
(DOM), and execute real workflows end to end. Manifest-driven: adding a pack
is one JSON row, no new test code.
## Prerequisites
1. A ComfyUI backend on `127.0.0.1:8288` with every manifest pack (the
`pack` entries in `browser_tests/fixtures/data/customNodeManifest.json`)
and ComfyUI_devtools
installed. Launch it with `--multi-user` (the repo-wide browser-test
prerequisite; the fixture writes per-worker user settings and the suite
depends on them landing), `--cache-none` (repeat runs must re-execute
every node or the executed-set check fails honestly with `PARTIAL`), and
with `browser_tests/assets/plain_video.mp4` copied into its `input/` dir.
2. The dev server proxying that backend:
`DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8288 pnpm dev`
## Running
| Script | What it does |
| -------------------------------------- | ------------------------------------------------------------------------------------- |
| `pnpm test:custom-nodes` | whole suite headless - the pass/fail gate (every tier passes, zero skips) |
| `pnpm test:custom-nodes:watch` | headed slow-motion run of the browser tiers, hands-off watching |
| `pnpm test:custom-nodes:debug` | step through the browser tiers in the Playwright Inspector (F10 step, F8 resume) |
| `pnpm test:custom-nodes:impact-render` | Impact nodes render in both renderers (Inspector) |
| `pnpm test:custom-nodes:impact-run` | Impact group workflow executes on the backend (Inspector) |
| `pnpm test:custom-nodes:vhs-render` | VHS nodes render in both renderers (Inspector) |
| `pnpm test:custom-nodes:vhs-run` | VHS decodes a real video through its node chain (Inspector) |
| `pnpm test:custom-nodes:connectivity` | slot/type contract: type-paired links + real slot drags in both renderers (Inspector) |
| `pnpm test:custom-nodes:self-check` | watches the harness catch a deliberate execution error |
Example - watch the VHS video-decode run step by step:
```bash
pnpm test:custom-nodes:vhs-run
```
Two windows open: the app under test and the Playwright Inspector. Press F10
to execute one robot action at a time (workflow loads, queue fires, backend
decodes the video), F8 to run to the end. While paused, look but do not click
inside the app window - your clicks change the state the next assertion
checks.
Any `-g` pattern works against the generic scripts, e.g.
`pnpm test:custom-nodes:debug -g "Impact-Pack.*T0"`.
## What the tests assert
- **T0 load**: pack nodes are registered in `/object_info`, added to a
cleared graph, counted exactly, and each added node's own `[data-node-id]`
element mounts under Vue Nodes 2.0. Both renderer passes - unless the pack
declares `vueNodesCompatible: false` in the manifest (evidence required;
see [ADDING_CUSTOM_NODES.md](ADDING_CUSTOM_NODES.md)), in which case its tests run their
LiteGraph-canvas assertions only. Never a skip.
- **T1 run**: the manifest workflow is loaded and queued; the backend's
`executing` event stream must contain every expected node id, and the run
must end in `execution_success`.
- **Every-node tiers** (`allNodes.spec.ts`): the pack's FULL node list,
discovered live from `/object_info`, is exercised with zero
configuration - every registered node mounts in both renderers (chunked
at an empirically calibrated batch size), survives a serialize/configure
save-reload round-trip, and executes for real on the backend when
self-sufficient (all required inputs are widgets with valid defaults).
Nodes that cannot run alone are classified and logged
(`NEEDS_WIRES` / `NEEDS_MODELS` / `NO_SINK` / rejected-at-validation),
never silently dropped; the documented exception ledgers (see
[ADDING_CUSTOM_NODES.md](ADDING_CUSTOM_NODES.md)) carry a written mechanism for every
escape hatch.
- **connectivity (contract)**: wiring-only, no execution. A
type-pairing generator (`fixtures/customNode/typePairing.ts`) indexes
`/object_info` producers/consumers and plans one representative typed edge
per slot (wildcard `*` slots excluded - they bypass the real type compare
and prove nothing). Each planned edge must connect through the real
`isValidConnection` veto, then survive `serialize()` -> `configure()` and
appear in `graphToPrompt()` output. A curated subset is additionally
dragged for real - slot dot to slot dot - under both renderers. Orphan
types (no partner in the corpus) are reported, never fake-failed. One
representative edge per slot bounds cost; it does not prove all pairs.
- **Zero visible errors, always**: every browser test asserts the app's
error surfaces (error overlay, error dialog, node render errors, error
toasts) are absent at start and after every pass. A run is green only if a
human watching the screen sees no errors. The self-check inverts this: it
forces a real execution error and asserts the overlay IS visible, proving
the selectors stay live.
## Adding a pack
One manifest row plus one small workflow JSON - no new test code. The
authoritative step-by-step process (verifying the pack's real node keys,
authoring the run workflow, the `vueNodesCompatible` evidence rule, what CI
does with the row) lives in [ADDING_CUSTOM_NODES.md](ADDING_CUSTOM_NODES.md). Follow it
exactly; the traps it lists all shipped in real packs.
## Gotchas
- **Pack frontend JS does not load under the Vite dev server.** The dev
server's `/extensions` endpoint lists core extensions only, so nodes render
vanilla locally even when the backend has the packs installed. CI serves
the built frontend from the backend, where every pack's JS loads and can
restyle nodes, rebuild widgets, or inject page chrome. Before pushing
changes that could interact with pack JS, reproduce CI locally:
`pnpm build`, relaunch the backend with `--front-end-root <repo>/dist`,
and run the suite with `PLAYWRIGHT_TEST_URL` pointed at the backend.
- Do not run with `--trace on` against system Chrome
(`playwright.chrome.config.ts` pins trace off): the trace recorder crashes
pages under the branded Chrome channel and every test reports a bogus 15s
timeout.
- In a git worktree whose `node_modules` is symlinked from another checkout,
prefix scripts with `pnpm --config.verify-deps-before-run=false ...` to
skip pnpm's auto-install check.
- First run against a cold dev server can exceed the 15s per-test setup
budget while Vite compiles; just run again.

View File

@@ -1,449 +0,0 @@
/* oxlint-disable playwright/no-skipped-test -- tiers conditionally skip when the target backend lacks the required packs; environment gating, not a disabled test */
// Every-node coverage: the suite's core contract (mounts, survives
// save/reload, executes when self-sufficient) applied to ALL nodes a pack
// registers - not just the curated expectedNodes sentinels. Node lists come
// from the live backend, so a pack update is covered the moment it installs.
import type { Page } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
batchAutoRunnable,
planAutoRuns
} from '@e2e/fixtures/customNode/autoRun'
import { LocalDesktopTarget } from '@e2e/fixtures/customNode/ComfyTarget'
import { loadManifest } from '@e2e/fixtures/customNode/manifest'
import type { RawNodeDef } from '@e2e/fixtures/customNode/typePairing'
import { normalizeNodeDefs } from '@e2e/fixtures/customNode/typePairing'
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
import {
customNodeSuiteSettings,
dismissTemplatesDialog
} from '@e2e/fixtures/utils/customNodeSuite'
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
const target = new LocalDesktopTarget()
// Empirically calibrated (browser_tests/tools/batchCalibration.spec.ts):
// 24 was the largest chunk size with deterministic results across repeats
// and the best per-node cost (6.6ms/node; smaller chunks pay per-chunk
// overhead, larger ones pay fit-view and DOM density).
const BATCH_SIZE = 24
const AUTO_RUN_BATCH = 10
const GRID_SPACING = { x: 420, y: 360 }
// Nodes whose EXECUTION is unsafe on a bare sandboxed backend even though
// their inputs classify as auto-runnable. Every entry names the mechanism.
// The canonical case: a node that downloads a model at execution time hangs
// a network-restricted backend in a non-interruptible call, jamming the
// prompt queue for everything after it.
const AUTO_RUN_EXCLUDE: Record<string, Record<string, string>> = {
'rgthree-comfy': {
'Power Primitive (rgthree)':
'requires its pack JS to build the primitive value at queue time; raw defaults KeyError. Whether a page applies pack JS varies by serving setup, so excluded unconditionally - curated-workflow candidate',
'Power Puter (rgthree)':
'requires its pack JS to compile the expression at queue time; raw defaults KeyError. Excluded unconditionally - curated-workflow candidate'
},
'ComfyUI-KJNodes': {
PointsEditor:
'requires its pack JS to inject the points JSON at queue time; raw defaults JSONDecodeError. Excluded unconditionally - curated-workflow candidate',
SplineEditor:
'requires its pack JS to inject the spline JSON at queue time; raw defaults JSONDecodeError. Excluded unconditionally - curated-workflow candidate',
StringToFloatList:
'requires its pack JS to normalize the list string at queue time; raw defaults ValueError. Excluded unconditionally - curated-workflow candidate'
},
ComfyUI_essentials: {
'RemBGSession+':
'initializes a rembg session that downloads its ONNX model at execution; hangs (non-interruptibly) on a backend without network/model access',
'TransitionMask+':
'list-expanded execution emits no per-node executing event on some runs, so the executed-set signal flip-flops between PASS and PARTIAL; mount/save-reload/connectivity tiers still cover it',
'TransparentBGSession+':
'ML-session initializer like RemBGSession+; sets up/downloads a background-removal model at execution, unstable on a bare backend'
}
}
// Pack-attributed console noise with no visible error surface. Every entry
// names the mechanism; anything not matching stays a hard failure.
const CONSOLE_ERROR_ALLOWLIST: Record<
string,
Array<{ pattern: RegExp; reason: string }>
> = {
'ComfyUI-KJNodes': [
{
// Image/video loader previews fetch their combo value at creation;
// on a backend with an empty input dir the value is undefined and the
// preview 404s (and retries with a fresh rand). Console-only noise,
// no visible error; upstream-report candidate.
pattern:
/Failed to load resource.*\/api\/view\?type=input&filename=undefined/,
reason: 'loader preview fetches undefined filename on empty input dir'
}
]
}
test.use({ initialSettings: customNodeSuiteSettings })
test.beforeEach(async ({ comfyPage }) => {
await dismissTemplatesDialog(comfyPage)
})
async function expectNoVisibleErrors(
page: Page,
context: string
): Promise<void> {
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
}
// One in-page round-trip per chunk: batch-add on a grid, fit the viewport.
// Returns ids aligned with `types` (null = createNode failed).
function addChunk(page: Page, types: string[]): Promise<Array<string | null>> {
return page.evaluate(
([chunk, spacingX, spacingY]) => {
window.app!.graph.clear()
const cols = Math.ceil(Math.sqrt(chunk.length))
const ids: Array<string | null> = []
for (const [index, type] of chunk.entries()) {
const node = window.LiteGraph!.createNode(type)
if (!node) {
ids.push(null)
continue
}
node.pos = [
(index % cols) * (spacingX as number),
Math.floor(index / cols) * (spacingY as number)
]
window.app!.graph.add(node)
ids.push(String(node.id))
}
const canvas = window.app!.canvas
const rect = canvas.canvas.getBoundingClientRect()
const width = cols * (spacingX as number)
const height = Math.ceil(chunk.length / cols) * (spacingY as number)
const scale = Math.min(
(rect.width / Math.max(width, 1)) * 0.9,
(rect.height / Math.max(height, 1)) * 0.9,
1
)
canvas.ds.scale = scale
canvas.ds.offset = [60 / scale, 60 / scale]
canvas.setDirty(true, true)
return ids
},
[types, GRID_SPACING.x, GRID_SPACING.y] as const
)
}
async function packNodeKeys(
page: Page,
pack: string
): Promise<{ keys: string[]; defs: Record<string, RawNodeDef> }> {
const defs = (await page.evaluate(() =>
window.app!.api.getNodeDefs()
)) as unknown as Record<string, RawNodeDef>
const keys = normalizeNodeDefs(defs)
.filter((node) => node.pack === pack)
.map((node) => node.type)
.sort()
return { keys, defs }
}
for (const entry of loadManifest()) {
test.describe(`all nodes: ${entry.pack}`, () => {
test('every registered node mounts in both renderers', async ({
comfyPage
}) => {
test.setTimeout(240_000)
const { keys } = await packNodeKeys(comfyPage.page, entry.pack)
test.skip(
keys.length === 0,
`${entry.pack} not installed on this backend`
)
const ledger = entry.vueIncompatibleNodes ?? {}
for (const ledgered of Object.keys(ledger))
expect(
keys,
`stale ledger entry: ${ledgered} is not registered by ${entry.pack}`
).toContain(ledgered)
for (const vueNodesEnabled of [false, true]) {
const consoleErrors = collectConsoleErrors(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
vueNodesEnabled
)
const failures: string[] = []
for (let offset = 0; offset < keys.length; offset += BATCH_SIZE) {
const chunk = keys.slice(offset, offset + BATCH_SIZE)
const ids = await addChunk(comfyPage.page, chunk)
await comfyPage.nextFrame()
const count = await comfyPage.nodeOps.getGraphNodesCount()
if (count !== chunk.length)
failures.push(
`chunk@${offset}: graph has ${count} of ${chunk.length} nodes`
)
for (const [index, id] of ids.entries()) {
const key = chunk[index]
if (id === null) {
failures.push(`${key}: createNode returned null`)
continue
}
if (!vueNodesEnabled) continue
if (key in ledger) continue
const visible = await comfyPage.page
.locator(`[data-node-id="${id}"]`)
.isVisible({ timeout: 2_000 })
.catch(() => false)
if (!visible) failures.push(`${key}: no Vue mount`)
}
}
if (vueNodesEnabled && Object.keys(ledger).length > 0)
console.log(
`${entry.pack}: ${Object.keys(ledger).length} node(s) ledgered Vue-incompatible; Vue mount not asserted for them`
)
consoleErrors.stop()
expect(
failures,
`VueNodes=${vueNodesEnabled}: ${JSON.stringify(failures, null, 1)}`
).toEqual([])
const allowlist = CONSOLE_ERROR_ALLOWLIST[entry.pack] ?? []
const allowed = consoleErrors.errors.filter((error) =>
allowlist.some((rule) => rule.pattern.test(error))
)
if (allowed.length > 0)
console.log(
`${entry.pack}: ${allowed.length} console error(s) matched the pack's allowlist (${allowlist.map((rule) => rule.reason).join('; ')})`
)
expect(
consoleErrors.errors.filter(
(error) => !allowlist.some((rule) => rule.pattern.test(error))
),
`console errors with VueNodes=${vueNodesEnabled}`
).toEqual([])
await expectNoVisibleErrors(
comfyPage.page,
`after all-nodes VueNodes=${vueNodesEnabled} pass`
)
}
})
test('every registered node survives save/reload', async ({
comfyPage
}) => {
test.setTimeout(240_000)
const { keys } = await packNodeKeys(comfyPage.page, entry.pack)
test.skip(
keys.length === 0,
`${entry.pack} not installed on this backend`
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
const mismatches: string[] = []
for (let offset = 0; offset < keys.length; offset += BATCH_SIZE) {
const chunk = keys.slice(offset, offset + BATCH_SIZE)
const chunkMismatches = await comfyPage.page.evaluate((types) => {
window.app!.graph.clear()
const before = new Map<
string,
{ type: string; widgetValues: number }
>()
for (const type of types) {
const node = window.LiteGraph!.createNode(type)
if (!node) continue
window.app!.graph.add(node)
before.set(String(node.id), {
type,
widgetValues: (node.widgets ?? []).length
})
}
const serialized = window.app!.graph.serialize()
window.app!.graph.configure(serialized)
const problems: string[] = []
for (const [id, expected] of before) {
const restored = window.app!.graph.getNodeById(Number(id))
if (!restored) {
problems.push(`${expected.type}: lost on reload`)
continue
}
if (restored.type !== expected.type)
problems.push(
`${expected.type}: type became ${String(restored.type)}`
)
const widgets = (restored.widgets ?? []).length
if (widgets !== expected.widgetValues)
problems.push(
`${expected.type}: widgets ${expected.widgetValues} -> ${widgets}`
)
}
window.app!.graph.clear()
return problems
}, chunk)
mismatches.push(...chunkMismatches)
}
expect(mismatches, JSON.stringify(mismatches, null, 1)).toEqual([])
await expectNoVisibleErrors(comfyPage.page, 'after save/reload sweep')
})
test('every auto-runnable node executes without error', async ({
comfyPage
}) => {
test.setTimeout(900_000)
const { keys, defs } = await packNodeKeys(comfyPage.page, entry.pack)
test.skip(
keys.length === 0,
`${entry.pack} not installed on this backend`
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
// A hung execution from an earlier test would make every run below
// false-timeout; fail fast with the real cause instead.
const queueBusy = await comfyPage.page.evaluate(async () => {
const queue = (await window.app!.api.getQueue()) as {
Running?: unknown[]
}
return (queue.Running ?? []).length
})
expect(
queueBusy,
'backend queue already has a running prompt (earlier hung execution?) - restart the test backend'
).toBe(0)
const excluded = AUTO_RUN_EXCLUDE[entry.pack] ?? {}
for (const [key, reason] of Object.entries(excluded))
console.log(`${entry.pack}: ${key} excluded from auto-run (${reason})`)
const verdicts = planAutoRuns(
defs,
keys.filter((key) => !(key in excluded))
)
const counts = new Map<string, number>()
for (const verdict of verdicts)
counts.set(verdict.verdict, (counts.get(verdict.verdict) ?? 0) + 1)
console.log(
`${entry.pack} auto-run plan: ${[...counts.entries()]
.map(([verdict, count]) => `${verdict}=${count}`)
.join(' ')}`
)
const batches = batchAutoRunnable(verdicts, AUTO_RUN_BATCH)
const hardFailures: string[] = []
// Nodes observed unable to run standalone: backend validation rejects
// their defaults, or execution errors on them (empty expressions,
// empty folders, no webcam). Reconciled against the committed
// cannotRunAlone baseline below - never silently dropped.
const cannotRun = new Map<string, string>()
const ranClean = new Set<string>()
for (const batch of batches) {
const outcome = await runBatch(comfyPage.page, batch)
if (outcome === 'PASS') {
for (const verdict of batch) ranClean.add(verdict.key)
continue
}
// A jammed queue makes every further run a false timeout - stop and
// name the suspects instead of bisecting through poisoned results.
if (outcome.startsWith('HUNG_BACKEND')) {
hardFailures.push(
`[${batch.map((verdict) => verdict.key).join(', ')}]: ${outcome} - add the offender to AUTO_RUN_EXCLUDE with its mechanism`
)
break
}
// Isolate: rerun each node alone so one bad node names itself
// instead of implicating its nine batch-mates.
for (const verdict of batch) {
const single = await runBatch(comfyPage.page, [verdict])
if (single === 'PASS') ranClean.add(verdict.key)
else if (single.startsWith('HUNG_BACKEND')) {
hardFailures.push(
`${verdict.key}: ${single} - add to AUTO_RUN_EXCLUDE with its mechanism`
)
break
} else cannotRun.set(verdict.key, single)
}
}
// Two-way baseline reconciliation. A failure missing from the baseline
// is a regression (a node that used to run clean broke); a baseline
// entry that now runs clean, or names a node that is not even
// auto-runnable, is stale and must be removed - the list cannot rot.
const baseline = new Set(entry.cannotRunAlone ?? [])
const runnable = new Set(
batches.flatMap((batch) => batch.map((verdict) => verdict.key))
)
for (const [key, detail] of cannotRun)
if (!baseline.has(key))
hardFailures.push(
`${key}: ${detail} - not in cannotRunAlone; a regression, or a new baseline entry (attach the run log)`
)
for (const key of baseline) {
if (ranClean.has(key))
hardFailures.push(
`${key}: ran clean but is listed in cannotRunAlone - remove the stale entry`
)
else if (!runnable.has(key))
hardFailures.push(
`${key}: listed in cannotRunAlone but is not auto-runnable on this backend - remove the stale entry`
)
}
console.log(
`${entry.pack} auto-ran ${ranClean.size} node(s) clean; ${cannotRun.size} cannot run alone (baseline ${baseline.size})`
)
expect(hardFailures, JSON.stringify(hardFailures, null, 1)).toEqual([])
})
})
}
async function runBatch(
page: Page,
batch: Array<{ key: string; needsPreviewSink?: boolean }>
): Promise<string> {
const ids = await page.evaluate(
([nodes, spacingY]) => {
window.app!.graph.clear()
const ids: string[] = []
for (const [index, spec] of nodes.entries()) {
const node = window.LiteGraph!.createNode(spec.key)
if (!node) continue
node.pos = [0, index * (spacingY as number)]
window.app!.graph.add(node)
ids.push(String(node.id))
if (spec.needsPreviewSink) {
const sink = window.LiteGraph!.createNode('PreviewAny')!
sink.pos = [460, index * (spacingY as number)]
window.app!.graph.add(sink)
node.connect(0, sink, 0)
}
}
return ids
},
[batch, GRID_SPACING.y] as const
)
// Auto-runnable nodes are widget-only and CPU-trivial; anything that has
// not finished in 20s is hung, and validation rejects return instantly.
const result = await target.runWorkflow(page, {
expectedNodeIds: ids,
timeoutMs: 20_000
})
if (result.outcome === 'TIMEOUT') {
// Leave no execution behind: an abandoned run jams the single prompt
// queue and every later run false-timeouts behind it. Interrupt, then
// verify the queue actually drained - a non-interruptible hang (e.g. a
// node blocked in a network download) can only be cleared by a backend
// restart, so name it instead of letting it poison the rest.
const drained = await page.evaluate(async () => {
await window.app!.api.interrupt()
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 500))
const queue = (await window.app!.api.getQueue()) as {
Running?: unknown[]
}
if ((queue.Running ?? []).length === 0) return true
}
return false
})
if (!drained)
return 'HUNG_BACKEND (non-interruptible execution; backend restart required)'
}
return result.outcome === 'PASS'
? 'PASS'
: `${result.outcome}${result.error?.nodeType ? ` (${result.error.nodeType}: ${result.error.exceptionType ?? ''})` : ''}`
}

View File

@@ -1,115 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
batchAutoRunnable,
classifyAutoRunnable,
planAutoRuns
} from '@e2e/fixtures/customNode/autoRun'
test.describe('autoRun classifier', () => {
test('widget-only node with outputs is runnable via a PreviewAny sink', () => {
const verdict = classifyAutoRunnable('IntConstant', {
input: { required: { value: ['INT', { default: 0 }] } },
output: ['INT'],
output_node: false
})
expect(verdict.verdict).toBe('AUTO_RUNNABLE')
expect(verdict.needsPreviewSink).toBe(true)
})
test('widget-only OUTPUT_NODE runs standalone', () => {
const verdict = classifyAutoRunnable('ShowValue', {
input: {
required: {
text: ['STRING', {}],
mode: [['raw value', 'tensor shape']]
}
},
output: [],
output_node: true
})
expect(verdict.verdict).toBe('AUTO_RUNNABLE')
expect(verdict.needsPreviewSink).toBe(false)
})
test('a required socket input means NEEDS_WIRES', () => {
const verdict = classifyAutoRunnable('VaeDecode', {
input: { required: { samples: ['LATENT'], vae: ['VAE'] } },
output: ['IMAGE'],
output_node: false
})
expect(verdict.verdict).toBe('NEEDS_WIRES')
expect(verdict.reason).toContain('samples')
})
test('forceInput STRING is a socket, not a widget', () => {
const verdict = classifyAutoRunnable('TextSink', {
input: { required: { text: ['STRING', { forceInput: true }] } },
output: ['STRING'],
output_node: true
})
expect(verdict.verdict).toBe('NEEDS_WIRES')
})
test('an empty required combo means NEEDS_MODELS', () => {
const verdict = classifyAutoRunnable('CheckpointLoader', {
input: { required: { ckpt_name: [[]] } },
output: ['MODEL'],
output_node: false
})
expect(verdict.verdict).toBe('NEEDS_MODELS')
expect(verdict.reason).toContain('ckpt_name')
})
test('no outputs and not an OUTPUT_NODE means NO_SINK', () => {
const verdict = classifyAutoRunnable('SideEffectOnly', {
input: { required: { value: ['INT', {}] } },
output: [],
output_node: false
})
expect(verdict.verdict).toBe('NO_SINK')
})
test('optional socket inputs do not block auto-running', () => {
const verdict = classifyAutoRunnable('MathWithOptionalAny', {
input: {
required: { expression: ['STRING', {}] },
optional: { a: ['*'] }
},
output: ['INT', 'FLOAT'],
output_node: true
})
expect(verdict.verdict).toBe('AUTO_RUNNABLE')
})
test('planAutoRuns maps keys and batchAutoRunnable chunks only runnables', () => {
const defs = {
A: {
input: { required: { v: ['INT', {}] } },
output: ['INT'],
output_node: false
},
B: {
input: { required: { x: ['LATENT'] } },
output: ['LATENT'],
output_node: false
},
C: {
input: { required: { v: ['FLOAT', {}] } },
output: ['FLOAT'],
output_node: false
}
}
const verdicts = planAutoRuns(defs, ['A', 'B', 'C'])
expect(verdicts.map((verdict) => verdict.verdict)).toEqual([
'AUTO_RUNNABLE',
'NEEDS_WIRES',
'AUTO_RUNNABLE'
])
const batches = batchAutoRunnable(verdicts, 1)
expect(batches).toHaveLength(2)
expect(batches[0][0].key).toBe('A')
})
})

View File

@@ -1,478 +0,0 @@
import type { Page } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
customNodeSuiteSettings,
dismissTemplatesDialog
} from '@e2e/fixtures/utils/customNodeSuite'
import { loadManifest } from '@e2e/fixtures/customNode/manifest'
import type {
ConnectivityOutcome,
PlannedPair,
RawNodeDef
} from '@e2e/fixtures/customNode/typePairing'
import {
isWildcard,
normalizeNodeDefs,
planPairs
} from '@e2e/fixtures/customNode/typePairing'
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
const CORE_PROOF_NODE_COUNT = 16
// A node may legitimately veto a wiring via onConnectInput; committed
// entries here must name the veto. Green means actual rejections are a
// subset of this list.
const CONNECT_REJECTED_ALLOWLIST: string[] = [
// pysssss MathExpression only accepts INT/FLOAT-producing links into its
// expression variables; its JS vetoes text-list producers.
'AddTextPrefix.texts -> MathExpression|pysssss.expression'
]
// A pack's own serialize/configure hooks may drop inbound links it manages
// itself; each committed entry must name the pack behavior. These reproduce
// in manual use of those packs (wire, save, reload: the link is gone), so
// they are pack behavior on record, not frontend regressions.
const ROUNDTRIP_LOST_ALLOWLIST: string[] = [
// rgthree SDXL Power Prompt rebuilds its dimension widget-inputs during
// configure and drops inbound links to them.
'BatchCount+.INT -> SDXL Power Prompt - Positive (rgthree).target_width',
'BatchCount+.INT -> SDXL Power Prompt - Positive (rgthree).target_height',
'BatchCount+.INT -> SDXL Power Prompt - Positive (rgthree).crop_width',
'BatchCount+.INT -> SDXL Power Prompt - Positive (rgthree).crop_height',
'BatchCount+.INT -> SDXL Power Prompt - Simple / Negative (rgthree).target_width',
'BatchCount+.INT -> SDXL Power Prompt - Simple / Negative (rgthree).target_height',
'BatchCount+.INT -> SDXL Power Prompt - Simple / Negative (rgthree).crop_width',
'BatchCount+.INT -> SDXL Power Prompt - Simple / Negative (rgthree).crop_height',
// VHS_SelectLatest rebuilds its dynamic slots on configure, detaching
// links on both its inputs and outputs.
'AddTextPrefix.texts -> VHS_SelectLatest.filename_prefix',
'AddTextPrefix.texts -> VHS_SelectLatest.filename_postfix',
'VHS_SelectLatest.Filename -> AddLabel.font_color'
]
test.use({ initialSettings: customNodeSuiteSettings })
test.beforeEach(async ({ comfyPage }) => {
await dismissTemplatesDialog(comfyPage)
})
async function expectNoVisibleErrors(
page: Page,
context: string
): Promise<void> {
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
}
function concrete(slot: { type: string }): boolean {
return !isWildcard(slot.type)
}
function isEntryInstalled(
nodeTypes: Set<string>,
entry: { expectedNodes: string[] }
): boolean {
return entry.expectedNodes.every((type) => nodeTypes.has(type))
}
const connectivityEntries = loadManifest().filter((entry) =>
entry.tiers.includes('connectivity')
)
test('connectivity: every type-paired link survives model, serialize, and prompt round-trips', async ({
comfyPage
}) => {
test.setTimeout(120_000)
const defs = (await comfyPage.page.evaluate(() =>
window.app!.api.getNodeDefs()
)) as unknown as Record<string, RawNodeDef>
const nodes = normalizeNodeDefs(defs)
// Pack-specific expectations apply only where the pack is installed; on a
// backend without it (e.g. a generic CI runner) the core sweep still runs
// and the absence is reported, never fake-failed or fake-passed.
const nodeTypes = new Set(nodes.map((node) => node.type))
const installedEntries = connectivityEntries.filter((entry) =>
isEntryInstalled(nodeTypes, entry)
)
for (const entry of connectivityEntries)
if (!installedEntries.includes(entry))
console.log(`connectivity: ${entry.pack} not installed on this backend`)
// Sweep EVERY node the installed packs register, not just the curated
// expectedNodes sentinels - the corpus comes from the live backend, so a
// pack update is covered the moment it installs.
const installedPacks = new Set(installedEntries.map((entry) => entry.pack))
const packTypes = nodes
.filter((node) => installedPacks.has(node.pack))
.map((node) => node.type)
const coreProof = nodes
.filter(
(node) =>
node.pack === 'core' &&
node.inputs.some(concrete) &&
node.outputs.some(concrete)
)
.map((node) => node.type)
.sort()
.slice(0, CORE_PROOF_NODE_COUNT)
const plan = planPairs(nodes, [...packTypes, ...coreProof])
expect(plan.pairs.length, 'pairing produced no edges').toBeGreaterThan(0)
console.log(
`connectivity plan: ${plan.pairs.length} pairs, ${plan.orphans.length} orphan slots, ${plan.wildcards.length} wildcard + ${plan.combos.length} combo slots (excluded by design)`
)
for (const entry of installedEntries) {
expect(
plan.pairs.some(
(pair) =>
pair.producer.pack === entry.pack || pair.consumer.pack === entry.pack
),
`${entry.pack} contributes no pairs - corpus or pack attribution broke`
).toBe(true)
}
const consoleErrors = collectConsoleErrors(comfyPage.page)
const results = await runPairsInPage(comfyPage.page, plan.pairs)
consoleErrors.stop()
expect(consoleErrors.errors, 'console errors during breadth sweep').toEqual(
[]
)
const widgetOnly = results.filter(
(result) =>
result.outcome ===
('WIDGET_ONLY_ON_INSTANCE' satisfies ConnectivityOutcome)
)
if (widgetOnly.length > 0)
console.log(
`connectivity sweep: ${widgetOnly.length} pair(s) excluded - pack JS made the declared input widget-only: ${widgetOnly.map((result) => result.key).join('; ')}`
)
const failures = results.filter(
(result) =>
result.outcome !== ('PASS' satisfies ConnectivityOutcome) &&
result.outcome !==
('WIDGET_ONLY_ON_INSTANCE' satisfies ConnectivityOutcome) &&
!(
result.outcome === ('CONNECT_REJECTED' satisfies ConnectivityOutcome) &&
CONNECT_REJECTED_ALLOWLIST.includes(result.key)
) &&
!(
result.outcome === ('ROUNDTRIP_LOST' satisfies ConnectivityOutcome) &&
ROUNDTRIP_LOST_ALLOWLIST.includes(result.key)
)
)
const passed = results.filter((result) => result.outcome === 'PASS').length
console.log(`connectivity sweep: ${passed}/${results.length} pairs PASS`)
expect(failures, JSON.stringify(failures, null, 1)).toEqual([])
expect(passed).toBeGreaterThan(0)
await expectNoVisibleErrors(comfyPage.page, 'after breadth sweep')
})
// Instance-level probe for the drag test: the first planned pair whose
// producer output AND consumer input both exist on freshly created node
// instances (pack JS can rebuild declared inputs as widget-only controls).
function firstMaterializedPair(
page: Page,
pairs: PlannedPair[]
): Promise<PlannedPair | null> {
return page.evaluate((pairsInPage) => {
for (const pair of pairsInPage) {
const producer = window.LiteGraph!.createNode(pair.producer.nodeType)
const consumer = window.LiteGraph!.createNode(pair.consumer.nodeType)
const outFound = producer?.outputs.some(
(slot) => slot.name === pair.producer.slotName
)
const inFound = consumer?.inputs.some(
(slot) => slot.name === pair.consumer.slotName
)
if (outFound && inFound) return pair
}
return null
}, pairs)
}
// The self-check below runs THIS SAME executor on poisoned pairs; if it stops
// being able to reject, every green sweep above is meaningless.
function runPairsInPage(
page: Page,
pairs: PlannedPair[]
): Promise<Array<{ key: string; outcome: string; detail?: string }>> {
return page.evaluate(async (pairsInPage) => {
const graph = window.app!.graph
const report: Array<{
key: string
outcome: string
detail?: string
}> = []
for (const pair of pairsInPage) {
const key = `${pair.producer.nodeType}.${pair.producer.slotName} -> ${pair.consumer.nodeType}.${pair.consumer.slotName}`
try {
graph.clear()
const producer = window.LiteGraph!.createNode(pair.producer.nodeType)
const consumer = window.LiteGraph!.createNode(pair.consumer.nodeType)
if (!producer || !consumer) {
report.push({
key,
outcome: 'SLOT_CONTRACT_MISMATCH',
detail: 'createNode returned null for a registered type'
})
continue
}
graph.add(producer)
graph.add(consumer)
const outIndex = producer.outputs.findIndex(
(slot) => slot.name === pair.producer.slotName
)
const inIndex = consumer.inputs.findIndex(
(slot) => slot.name === pair.consumer.slotName
)
if (outIndex < 0 || inIndex < 0) {
// A pack's own frontend JS may rebuild a declared input as a
// widget-only control (rgthree's Seed does this to `seed`). That is
// pack design, not a wiring regression - excluded like wildcards.
// A name that exists NEITHER as slot nor widget stays a hard fail.
const widgetOnly =
outIndex >= 0 &&
(consumer.widgets ?? []).some(
(widget) => widget.name === pair.consumer.slotName
)
report.push({
key,
outcome: widgetOnly
? 'WIDGET_ONLY_ON_INSTANCE'
: 'SLOT_CONTRACT_MISMATCH',
detail: `declared slot missing on instance (out=${outIndex}, in=${inIndex})`
})
continue
}
const link = producer.connect(outIndex, consumer, inIndex)
if (!link || consumer.inputs[inIndex]?.link == null) {
report.push({ key, outcome: 'CONNECT_REJECTED' })
continue
}
const serialized = graph.serialize()
graph.configure(serialized)
const restored = graph.getNodeById(consumer.id)
if (restored?.inputs?.[inIndex]?.link == null) {
report.push({
key,
outcome: 'ROUNDTRIP_LOST',
detail: 'serialize/configure dropped the link'
})
continue
}
const prompt = (await window.app!.graphToPrompt()) as {
output?: Record<string, { inputs?: Record<string, unknown> }>
}
const promptInput =
prompt.output?.[String(consumer.id)]?.inputs?.[pair.consumer.slotName]
if (!Array.isArray(promptInput)) {
report.push({
key,
outcome: 'ROUNDTRIP_LOST',
detail: 'link missing from graphToPrompt output'
})
continue
}
report.push({ key, outcome: 'PASS' })
} catch (error) {
report.push({
key,
outcome: 'SLOT_CONTRACT_MISMATCH',
detail: `threw: ${String(error)}`
})
}
}
graph.clear()
return report
}, pairs)
}
test('connectivity self-check: the executor rejects broken pairs', async ({
comfyPage
}) => {
const slot = (nodeType: string, slotName: string, slotType: string) => ({
nodeType,
pack: 'core',
slotName,
slotType
})
const results = await runPairsInPage(comfyPage.page, [
{
producer: slot('CheckpointLoaderSimple', 'MODEL', 'MODEL'),
consumer: slot('KSampler', 'latent_image', 'LATENT')
},
{
producer: slot('EmptyLatentImage', 'LATENT', 'LATENT'),
consumer: slot('KSampler', 'does_not_exist', 'LATENT')
}
])
expect(results.map((result) => result.outcome)).toEqual([
'CONNECT_REJECTED',
'SLOT_CONTRACT_MISMATCH'
])
})
test('connectivity drags: curated slot-to-slot wires connect under both renderers', async ({
comfyPage
}) => {
test.setTimeout(120_000)
const defs = (await comfyPage.page.evaluate(() =>
window.app!.api.getNodeDefs()
)) as unknown as Record<string, RawNodeDef>
const nodes = normalizeNodeDefs(defs)
// Native anchor pair plus one in-pack, link-typed pair per connectivity
// pack (derived from the same generator the breadth sweep uses).
const dragEdges: PlannedPair[] = [
{
producer: {
nodeType: 'EmptyLatentImage',
pack: 'core',
slotName: 'LATENT',
slotType: 'LATENT'
},
consumer: {
nodeType: 'KSampler',
pack: 'core',
slotName: 'latent_image',
slotType: 'LATENT'
}
}
]
const nodeTypes = new Set(nodes.map((node) => node.type))
for (const entry of connectivityEntries) {
if (!isEntryInstalled(nodeTypes, entry)) {
console.log(
`connectivity drag: ${entry.pack} not installed on this backend`
)
continue
}
// Restrict the partner pool to the pack itself so the drag proves an
// in-pack wiring; widget-backed primitive inputs render real slot dots
// in Vue (verified empirically), so no slot type is excluded at plan time.
const packPlan = planPairs(
nodes.filter((node) => node.pack === entry.pack),
entry.expectedNodes
)
expect(
packPlan.pairs.length,
`${entry.pack} has no in-pack draggable pair - drag coverage lost`
).toBeGreaterThan(0)
// The plan comes from object_info, but a pack's own JS can rebuild a
// declared input as widget-only on the instance (rgthree's Seed does).
// Drag the first pair whose slots actually materialize; a pack whose
// every planned pair is customized away has no socket contract to drag.
const inPack = await firstMaterializedPair(comfyPage.page, packPlan.pairs)
if (!inPack) {
console.log(
`connectivity drag: ${entry.pack} planned pairs are widget-only on instances; drag not applicable`
)
continue
}
dragEdges.push(inPack)
}
const vueIncompatiblePacks = new Set(
connectivityEntries
.filter((entry) => entry.vueNodesCompatible === false)
.map((entry) => entry.pack)
)
for (const vueNodesEnabled of [false, true]) {
const consoleErrors = collectConsoleErrors(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
vueNodesEnabled
)
for (const edge of dragEdges) {
if (vueNodesEnabled && vueIncompatiblePacks.has(edge.producer.pack)) {
console.log(
`connectivity drag: ${edge.producer.pack} declares vueNodesCompatible=false; Vue drag not applicable`
)
continue
}
await comfyPage.nodeOps.clearGraph()
const producer = await comfyPage.nodeOps.addNode(
edge.producer.nodeType,
undefined,
{ x: 150, y: 200 }
)
const consumer = await comfyPage.nodeOps.addNode(
edge.consumer.nodeType,
undefined,
{ x: 700, y: 200 }
)
await comfyPage.nextFrame()
const [outIndex, inIndex] = await comfyPage.page.evaluate(
([producerId, consumerId, outName, inName]) => {
const byId = (id: string) =>
window.app!.graph.nodes.find((node) => String(node.id) === id)!
const src = byId(producerId)
const dst = byId(consumerId)
return [
src.outputs.findIndex((slot) => slot.name === outName),
dst.inputs.findIndex((slot) => slot.name === inName)
]
},
[
String(producer.id),
String(consumer.id),
edge.producer.slotName,
edge.consumer.slotName
] as const
)
const key = `${edge.producer.nodeType}.${edge.producer.slotName} -> ${edge.consumer.nodeType}.${edge.consumer.slotName}`
expect(outIndex, `${key}: producer slot on instance`).toBeGreaterThan(-1)
expect(inIndex, `${key}: consumer slot on instance`).toBeGreaterThan(-1)
if (vueNodesEnabled) {
await comfyPage.vueNodes.waitForNodes(2)
// Output-side mirror of getInputSlotConnectionDot, addressed by
// data-slot-key so shared-label ambiguity cannot misfire the drag.
const outDot = comfyPage.page
.locator(`[data-node-id="${String(producer.id)}"]`)
.locator('.lg-slot--output')
.filter({
has: comfyPage.page.locator(
`[data-slot-key="${String(producer.id)}-out-${outIndex}"]`
)
})
.getByTestId('slot-connection-dot')
const inDot = comfyPage.vueNodes.getInputSlotConnectionDot(
String(consumer.id),
inIndex
)
await outDot.dragTo(inDot)
} else {
await producer.connectOutput(outIndex, consumer, inIndex)
}
const linked = await comfyPage.page.evaluate(
([consumerId, index]) => {
const node = window.app!.graph.nodes.find(
(candidate) => String(candidate.id) === consumerId
)
return node?.inputs?.[Number(index)]?.link != null
},
[String(consumer.id), String(inIndex)] as const
)
expect(linked, `${key} with VueNodes=${vueNodesEnabled}`).toBe(true)
}
consoleErrors.stop()
expect(
consoleErrors.errors,
`console errors with VueNodes=${vueNodesEnabled}`
).toEqual([])
await expectNoVisibleErrors(
comfyPage.page,
`after drag pass VueNodes=${vueNodesEnabled}`
)
}
})

View File

@@ -1,58 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
customNodeSuiteSettings,
dismissTemplatesDialog
} from '@e2e/fixtures/utils/customNodeSuite'
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
import { assetPath } from '@e2e/fixtures/utils/paths'
// Core-only, model-free workflow: the bundled default template references
// model files a scoped test backend does not have, which rightly trips the
// error surfaces this suite asserts are clean.
const smokeWorkflow = JSON.parse(
readFileSync(resolve(assetPath('customNodes/core_smoke.json')), 'utf-8')
) as ComfyWorkflowJSON
test.use({ initialSettings: customNodeSuiteSettings })
test.beforeEach(async ({ comfyPage }) => {
await dismissTemplatesDialog(comfyPage)
})
test.describe('smoke: core workflow', () => {
test('loads without console errors in both renderers', async ({
comfyPage
}) => {
for (const vueNodesEnabled of [false, true]) {
const consoleErrors = collectConsoleErrors(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
vueNodesEnabled
)
await comfyPage.workflow.loadGraphData(smokeWorkflow)
await comfyPage.nextFrame()
consoleErrors.stop()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBeGreaterThan(0)
expect(
consoleErrors.errors,
`console errors (VueNodes=${vueNodesEnabled})`
).toEqual([])
for (const [surface, locator] of Object.entries(
errorSurfaces(comfyPage.page)
))
await expect(
locator,
`${surface} (VueNodes=${vueNodesEnabled})`
).toHaveCount(0)
}
})
})

View File

@@ -1,191 +0,0 @@
/* oxlint-disable playwright/no-skipped-test -- tiers conditionally skip when the target backend lacks the required packs (installed custom nodes or devtools); this is the framework's designed environment gating, not a disabled test */
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Page } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
customNodeSuiteSettings,
dismissTemplatesDialog
} from '@e2e/fixtures/utils/customNodeSuite'
import { LocalDesktopTarget } from '@e2e/fixtures/customNode/ComfyTarget'
import {
loadManifest,
rendererPassesFor
} from '@e2e/fixtures/customNode/manifest'
import { expectedNodesPresent } from '@e2e/fixtures/customNode/objectInfoValidator'
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
import { assetPath } from '@e2e/fixtures/utils/paths'
const target = new LocalDesktopTarget()
const OBJECT_INFO_SANITY_FLOOR = 50
test.use({ initialSettings: customNodeSuiteSettings })
test.beforeEach(async ({ comfyPage }) => {
await dismissTemplatesDialog(comfyPage)
})
async function expectNoVisibleErrors(
page: Page,
context: string
): Promise<void> {
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
}
function readWorkflow(relativePath: string): ComfyWorkflowJSON {
return JSON.parse(
readFileSync(resolve(relativePath), 'utf-8')
) as ComfyWorkflowJSON
}
async function nodeIdsByType(
page: Page,
classTypes: string[]
): Promise<string[]> {
return await page.evaluate((types) => {
const nodes = window.app!.graph.nodes ?? []
return nodes
.filter((node) => {
const n = node as { comfyClass?: string; type?: string }
return types.includes(n.comfyClass ?? n.type ?? '')
})
.map((node) => String(node.id))
}, classTypes)
}
for (const entry of loadManifest()) {
const workflowRelative = `browser_tests/${entry.workflow}`
test.describe(`custom node: ${entry.pack}`, () => {
test('T0 load: expected nodes register and render in both renderers', async ({
comfyPage
}) => {
test.setTimeout(entry.timeoutMs)
const objectInfo = await target.getObjectInfo(comfyPage.page)
expect(
Object.keys(objectInfo).length,
'object_info sanity floor'
).toBeGreaterThan(OBJECT_INFO_SANITY_FLOOR)
const { missing } = expectedNodesPresent(objectInfo, entry.expectedNodes)
test.skip(
missing.length > 0,
`${entry.pack} not installed on this backend (missing: ${missing.join(', ')})`
)
await expectNoVisibleErrors(comfyPage.page, 'at startup')
// A pack that declares vueNodesCompatible: false is exercised under the
// LiteGraph canvas only - rendering its nodes under Vue Nodes 2.0 would
// fail for a known pack limitation, not a frontend regression. This is
// conditional coverage, not a test skip: the test still runs and gates.
const rendererPasses = rendererPassesFor(entry)
if (entry.vueNodesCompatible === false)
console.log(
`${entry.pack} declares vueNodesCompatible=false; Vue Nodes pass not applicable`
)
for (const vueNodesEnabled of rendererPasses) {
const consoleErrors = collectConsoleErrors(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
vueNodesEnabled
)
await comfyPage.nodeOps.clearGraph()
const addedIds: string[] = []
for (const classType of entry.expectedNodes) {
const node = await comfyPage.nodeOps.addNode(classType)
addedIds.push(String(node.id))
}
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
entry.expectedNodes.length
)
// Vue Nodes 2.0 mounts each node as a [data-node-id] element; assert
// the pack's own nodes rendered, not just any node count.
if (vueNodesEnabled)
for (const id of addedIds)
await expect(comfyPage.vueNodes.getNodeLocator(id)).toBeVisible()
consoleErrors.stop()
expect(
consoleErrors.errors,
`console errors with VueNodes=${vueNodesEnabled}`
).toEqual([])
await expectNoVisibleErrors(
comfyPage.page,
`after VueNodes=${vueNodesEnabled} pass`
)
}
})
test('T1 run: workflow executes without error', async ({ comfyPage }) => {
test.setTimeout(entry.timeoutMs + 15_000)
const objectInfo = await target.getObjectInfo(comfyPage.page)
const { missing } = expectedNodesPresent(objectInfo, entry.expectedNodes)
test.skip(
!entry.tiers.includes('run') ||
missing.length > 0 ||
entry.requiresGpu ||
entry.requiresModels.length > 0 ||
!entry.workflow ||
!existsSync(resolve(workflowRelative)),
`run tier unavailable for ${entry.pack}`
)
await expectNoVisibleErrors(comfyPage.page, 'at startup')
await comfyPage.workflow.loadGraphData(readWorkflow(workflowRelative))
const result = await target.runWorkflow(comfyPage.page, {
expectedNodeIds: await nodeIdsByType(
comfyPage.page,
entry.expectedNodes
),
timeoutMs: entry.timeoutMs
})
expect(result.outcome, JSON.stringify(result.error ?? {})).toBe('PASS')
await expectNoVisibleErrors(comfyPage.page, 'after run')
})
})
}
test('harness self-check: captures a real execution error', async ({
comfyPage
}) => {
test.setTimeout(30_000)
const objectInfo = await target.getObjectInfo(comfyPage.page)
expect(
Object.keys(objectInfo).length,
'object_info sanity floor'
).toBeGreaterThan(OBJECT_INFO_SANITY_FLOOR)
test.skip(
!('DevToolsErrorRaiseNode' in objectInfo),
'ComfyUI_devtools not installed on this backend'
)
await comfyPage.workflow.loadGraphData(
readWorkflow(assetPath('nodes/execution_error.json'))
)
const result = await target.runWorkflow(comfyPage.page, {
expectedNodeIds: [],
timeoutMs: 15000
})
expect(result.outcome).toBe('EXECUTION_ERROR')
expect(result.error?.exceptionType).toBeTruthy()
// Proves the event tap captures node ids from the live `executing` stream
// (its detail is a bare string): the failing node starts before it raises.
expect(result.executedNodes.length).toBeGreaterThan(0)
// Positive control for the zero-visible-errors invariant: a real execution
// error MUST surface in the app's error overlay. If this fails, the
// expectNoVisibleErrors selectors have rotted and every clean assertion in
// this suite is meaningless.
await expect(errorSurfaces(comfyPage.page).errorOverlay).toBeVisible()
})

View File

@@ -1,29 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
loadManifest,
rendererPassesFor
} from '@e2e/fixtures/customNode/manifest'
test.describe('customNode manifest', () => {
test('loads entries with the shape the regression spec depends on', () => {
const entries = loadManifest()
expect(entries.length).toBeGreaterThan(0)
for (const entry of entries) {
expect(entry.pack).toBeTruthy()
expect(entry.expectedNodes.length).toBeGreaterThan(0)
expect(entry.tiers.length).toBeGreaterThan(0)
}
})
test('rendererPassesFor drops only the Vue pass, only on an explicit false', () => {
expect(rendererPassesFor({})).toEqual([false, true])
expect(rendererPassesFor({ vueNodesCompatible: true })).toEqual([
false,
true
])
expect(rendererPassesFor({ vueNodesCompatible: false })).toEqual([false])
})
})

View File

@@ -1,47 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
import {
expectedNodesPresent,
preValidate
} from '@e2e/fixtures/customNode/objectInfoValidator'
const objectInfo: ObjectInfo = {
KSampler: { input: { required: { model: {}, seed: {} } } }
}
test.describe('objectInfoValidator', () => {
test('expectedNodesPresent splits present from missing', () => {
const { present, missing } = expectedNodesPresent(objectInfo, [
'KSampler',
'Missing (rgthree)'
])
expect(present).toEqual(['KSampler'])
expect(missing).toEqual(['Missing (rgthree)'])
})
test('preValidate returns MISSING_NODE for an unregistered class', () => {
const failure = preValidate(objectInfo, [
{ id: '1', classType: 'Ghost', inputs: {} }
])
expect(failure?.outcome).toBe('MISSING_NODE')
})
test('preValidate returns VALIDATION_FAIL naming the missing required input', () => {
const failure = preValidate(objectInfo, [
{ id: '3', classType: 'KSampler', inputs: { model: 0 } }
])
expect(failure?.outcome).toBe('VALIDATION_FAIL')
expect(failure?.message).toContain('missing required input "seed"')
})
test('preValidate passes when every required input is present', () => {
expect(
preValidate(objectInfo, [
{ id: '3', classType: 'KSampler', inputs: { model: 0, seed: 1 } }
])
).toBeNull()
})
})

View File

@@ -1,71 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
test.describe('classifyRun', () => {
test('PASS when every expected node appears in the executing stream', () => {
const result = classifyRun({
events: [
{ type: 'execution_start' },
{ type: 'executing', node: '1' },
{ type: 'executing', node: '2' },
{ type: 'executing', node: null },
{ type: 'execution_success' }
],
expectedNodeIds: ['1', '2']
})
expect(result.outcome).toBe('PASS')
expect(result.executedNodes).toEqual(['1', '2'])
})
test('PARTIAL when a succeeding run replays a cached node that never emitted executing', () => {
const result = classifyRun({
events: [{ type: 'executing', node: '1' }, { type: 'execution_success' }],
expectedNodeIds: ['1', '2']
})
expect(result.outcome).toBe('PARTIAL')
expect(result.executedNodes).toEqual(['1'])
})
test('EXECUTION_ERROR captures the failing node details', () => {
const result = classifyRun({
events: [
{ type: 'executing', node: '1' },
{
type: 'execution_error',
error: { exceptionType: 'ValueError', nodeId: '1' }
}
],
expectedNodeIds: ['1']
})
expect(result.outcome).toBe('EXECUTION_ERROR')
expect(result.error?.exceptionType).toBe('ValueError')
})
test('EXECUTION_ERROR when the run is interrupted', () => {
const result = classifyRun({
events: [
{ type: 'executing', node: '1' },
{ type: 'execution_interrupted' }
],
expectedNodeIds: ['1']
})
expect(result.outcome).toBe('EXECUTION_ERROR')
})
test('TIMEOUT when flagged or when no terminal event arrived', () => {
const flagged = classifyRun({
events: [{ type: 'executing', node: '1' }],
expectedNodeIds: ['1'],
timedOut: true
})
const noTerminal = classifyRun({
events: [{ type: 'executing', node: '1' }],
expectedNodeIds: ['1']
})
expect(flagged.outcome).toBe('TIMEOUT')
expect(noTerminal.outcome).toBe('TIMEOUT')
})
})

View File

@@ -1,139 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { RawNodeDef } from '@e2e/fixtures/customNode/typePairing'
import {
isTypeCompatible,
normalizeNodeDefs,
packOf,
planPairs
} from '@e2e/fixtures/customNode/typePairing'
const DEFS: Record<string, RawNodeDef> = {
LatentSource: {
input: { required: {} },
output: ['LATENT'],
output_name: ['LATENT'],
python_module: 'nodes'
},
LatentSink: {
input: { required: { latent: ['LATENT', {}] } },
output: [],
python_module: 'custom_nodes.SomePack'
},
UnionSource: {
input: { required: {} },
output: ['STRING,INT'],
output_name: ['value'],
python_module: 'nodes'
},
IntSink: {
input: { required: { value: ['int', {}] } },
output: [],
python_module: 'nodes'
},
ComboNode: {
input: { required: { choice: [['a', 'b'], {}] } },
output: [],
python_module: 'nodes'
},
SocketlessNode: {
input: { required: { hidden: ['STRING', { socketless: true }] } },
output: [],
python_module: 'nodes'
},
WildcardNode: {
input: { required: { anything: ['*', {}] } },
output: ['*'],
output_name: ['out'],
python_module: 'nodes'
},
OrphanNode: {
input: { required: {} },
output: ['NOBODY_CONSUMES_THIS'],
output_name: ['orphan'],
python_module: 'custom_nodes.OrphanPack'
}
}
test.describe('typePairing', () => {
test('isTypeCompatible mirrors the real validator semantics', () => {
expect(isTypeCompatible('LATENT', 'LATENT')).toBe(true)
expect(isTypeCompatible('latent', 'LATENT')).toBe(true)
expect(isTypeCompatible('LATENT', 'IMAGE')).toBe(false)
expect(isTypeCompatible('STRING,INT', 'INT')).toBe(true)
expect(isTypeCompatible('STRING,INT', 'FLOAT')).toBe(false)
expect(isTypeCompatible('*', 'ANYTHING')).toBe(true)
expect(isTypeCompatible('', 'ANYTHING')).toBe(true)
})
test('packOf attributes core vs custom pack', () => {
expect(packOf('nodes')).toBe('core')
expect(packOf('comfy_extras.nodes_x')).toBe('core')
expect(packOf('custom_nodes.ComfyUI-Impact-Pack')).toBe(
'ComfyUI-Impact-Pack'
)
expect(packOf(undefined)).toBe('core')
})
test('normalize maps COMBO literals and drops socketless inputs', () => {
const nodes = normalizeNodeDefs(DEFS)
const combo = nodes.find((n) => n.type === 'ComboNode')!
expect(combo.inputs).toEqual([{ name: 'choice', type: 'COMBO' }])
const socketless = nodes.find((n) => n.type === 'SocketlessNode')!
expect(socketless.inputs).toEqual([])
})
test('planPairs pairs exact and union types, deterministically', () => {
const nodes = normalizeNodeDefs(DEFS)
const plan = planPairs(nodes, ['LatentSink', 'IntSink'])
const keys = plan.pairs.map(
(p) =>
`${p.producer.nodeType}.${p.producer.slotName}->${p.consumer.nodeType}.${p.consumer.slotName}`
)
expect(keys).toContain('LatentSource.LATENT->LatentSink.latent')
expect(keys).toContain('UnionSource.value->IntSink.value')
const again = planPairs(nodes, ['LatentSink', 'IntSink'])
expect(again.pairs).toEqual(plan.pairs)
})
test('COMBO literals are excluded from pairing with names coerced to strings', () => {
const nodes = normalizeNodeDefs({
ComboSource: {
input: { required: {} },
output: [['A', 'B', 'C']],
output_name: [['A', 'B', 'C'] as unknown as string],
python_module: 'nodes'
},
...DEFS
})
const source = nodes.find((n) => n.type === 'ComboSource')!
expect(source.outputs).toEqual([{ name: 'COMBO', type: 'COMBO' }])
const plan = planPairs(nodes, ['ComboSource', 'ComboNode'])
expect(plan.pairs).toEqual([])
expect(plan.combos.map((s) => `${s.nodeType}.${s.slotName}`)).toEqual([
'ComboSource.COMBO',
'ComboNode.choice'
])
})
test('wildcard slots are excluded, orphan types recorded not failed', () => {
const nodes = normalizeNodeDefs(DEFS)
const plan = planPairs(nodes, ['WildcardNode', 'OrphanNode'])
expect(plan.wildcards.map((w) => w.nodeType)).toEqual([
'WildcardNode',
'WildcardNode'
])
expect(plan.orphans).toEqual([
{
nodeType: 'OrphanNode',
pack: 'OrphanPack',
slotName: 'orphan',
slotType: 'NOBODY_CONSUMES_THIS',
dir: 'out'
}
])
expect(plan.pairs).toEqual([])
})
})

View File

@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -691,7 +691,8 @@ test(
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
const { width, height } = (await stepsSlot.boundingBox())!
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1238,7 +1238,7 @@ test(
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
@@ -1251,7 +1251,7 @@ test(
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')

View File

@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> => {
): Promise<{ x: number; y: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.boundingBox()
if (!box) throw new Error(`${title} header not found`)
return box
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test('should allow moving nodes by dragging', async ({
comfyPage,
comfyMouse
}) => {
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expectPosChanged(initialHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
comfyPage,
comfyMouse
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
// Re-fetch drag source after clicks in case the header reflowed.
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
const centerX = dragSrc.x + dragSrc.width / 2
const centerY = dragSrc.y + dragSrc.height / 2
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
steps: 20
})
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await expect(pinIndicator2).toBeHidden()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
test('should not allow dragging pinned nodes', async ({
comfyMouse,
comfyPage
}) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is not dragged (same position before and after click-and-drag)
await expect
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node again
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is dragged
await expect

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

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