Compare commits
32 Commits
jaeone/ref
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7adfaa9079 | ||
|
|
1e36107109 | ||
|
|
1d5514c90e | ||
|
|
61d1cbfdb0 | ||
|
|
14666b09c4 | ||
|
|
efb0365bc3 | ||
|
|
065bc0c336 | ||
|
|
1248c4628a | ||
|
|
ee83d67834 | ||
|
|
f63b7d866e | ||
|
|
068191ea47 | ||
|
|
07c4b230b2 | ||
|
|
9ed51f1e4b | ||
|
|
4a91fa4849 | ||
|
|
0991905a89 | ||
|
|
df6764762b | ||
|
|
2d2b318450 | ||
|
|
0f94da8746 | ||
|
|
d80427d014 | ||
|
|
d02e665290 | ||
|
|
dc83cc4df6 | ||
|
|
8b81a4f359 | ||
|
|
8f567e8ef0 | ||
|
|
4fb282f853 | ||
|
|
d17a387ddb | ||
|
|
68ba0aa613 | ||
|
|
675140c164 | ||
|
|
64706c53c3 | ||
|
|
bfa94d4118 | ||
|
|
b7708d5ad0 | ||
|
|
564de12d46 | ||
|
|
5a1f788230 |
86
.claude/settings.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,27 +134,6 @@ 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
|
||||
|
||||
154
.github/workflows/ci-tests-custom-nodes.yaml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
# 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
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,7 +95,6 @@ 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'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
42
.github/workflows/cla.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -18,45 +17,13 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,10 +39,9 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,13 +32,12 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(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'))))))
|
||||
(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
|
||||
|
||||
@@ -21,7 +21,6 @@ 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') ||
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -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 max-width="xl" :label="eyebrow" align="start">
|
||||
<SectionHeader :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">
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -30,9 +28,11 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +38,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp },
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,41 +33,36 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -29,7 +27,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -39,11 +37,10 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<a
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -54,27 +51,23 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -15,10 +14,11 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,45 +32,48 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
}
|
||||
]
|
||||
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,18 +100,23 @@ const cardClass =
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="cardClass"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -126,6 +134,11 @@ const cardClass =
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -155,14 +168,10 @@ const cardClass =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.getStarted', locale),
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: routes.mcp
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@@ -82,7 +82,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
|
||||
@@ -26,10 +26,6 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -932,9 +928,9 @@ const translations = {
|
||||
'zh-CN': '所有模型。\n商业许可保证。'
|
||||
},
|
||||
'cloud.reason.2.description': {
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
'zh-CN':
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
},
|
||||
'cloud.reason.2.badge.onlyOn': {
|
||||
en: 'ONLY ON',
|
||||
@@ -996,10 +992,6 @@ const translations = {
|
||||
en: 'Wan 2.2',
|
||||
'zh-CN': 'Wan 2.2'
|
||||
},
|
||||
'cloud.aiModels.card.gptImage2': {
|
||||
en: 'GPT Image 2',
|
||||
'zh-CN': 'GPT Image 2'
|
||||
},
|
||||
'cloud.aiModels.ctaDesktop': {
|
||||
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
|
||||
'zh-CN': '探索最新模型工作流'
|
||||
@@ -2191,7 +2183,6 @@ const translations = {
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
@@ -2205,16 +2196,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: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -123,6 +123,15 @@ 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:
|
||||
|
||||
53
browser_tests/assets/customNodes/core_smoke.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
98
browser_tests/assets/customNodes/impact_primitives_run.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
98
browser_tests/assets/customNodes/kjnodes_constants_run.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
107
browser_tests/assets/customNodes/vhs_video_pipeline_run.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
103
browser_tests/assets/customNodes/was_number_text_run.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -268,8 +268,16 @@ export class ComfyPage {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
if (resp.status() !== 200)
|
||||
throw new Error(`Failed to create user: ${await resp.text()}`)
|
||||
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}`)
|
||||
}
|
||||
|
||||
return await resp.json()
|
||||
}
|
||||
@@ -537,6 +545,7 @@ 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,
|
||||
|
||||
141
browser_tests/fixtures/customNode/ComfyTarget.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
108
browser_tests/fixtures/customNode/autoRun.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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
|
||||
}
|
||||
124
browser_tests/fixtures/customNode/manifest.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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
|
||||
}
|
||||
54
browser_tests/fixtures/customNode/objectInfoValidator.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
}
|
||||
72
browser_tests/fixtures/customNode/runResult.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 }
|
||||
}
|
||||
216
browser_tests/fixtures/customNode/typePairing.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// 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
|
||||
}
|
||||
132
browser_tests/fixtures/data/customNodeManifest.json
Normal file
@@ -0,0 +1,132 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -6,10 +6,6 @@ import type {
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
const ROUTE_PATTERN_WORKFLOW_TEMPLATES = /\/api\/workflow_templates(?:\?.*)?$/
|
||||
const ROUTE_PATTERN_TEMPLATE_INDEX = /\/templates\/index\.json(?:\?.*)?$/
|
||||
const ROUTE_PATTERN_TEMPLATE_THUMBNAILS = /\/templates\/.*\.webp(?:\?.*)?$/
|
||||
|
||||
interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
@@ -45,6 +41,10 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
@@ -64,30 +64,29 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockCustomTemplates()
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockCustomTemplates(): Promise<void> {
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '{}',
|
||||
body: JSON.stringify(customTemplates),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
await this.page.route(
|
||||
ROUTE_PATTERN_WORKFLOW_TEMPLATES,
|
||||
customTemplatesHandler
|
||||
)
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
@@ -99,8 +98,9 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
@@ -114,8 +114,12 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
@@ -125,6 +129,15 @@ export class TemplateHelper {
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
|
||||
@@ -110,8 +110,7 @@ export const TestIds = {
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors',
|
||||
selectionContextStrip: 'selection-context-strip'
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
|
||||
@@ -7,6 +7,10 @@ export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
await use(createTemplateHelper(page))
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
17
browser_tests/fixtures/utils/consoleErrorCollector.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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) }
|
||||
}
|
||||
27
browser_tests/fixtures/utils/customNodeSuite.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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' })
|
||||
}
|
||||
16
browser_tests/fixtures/utils/errorSurfaces.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,14 @@ 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(
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
332
browser_tests/tests/customNodes/ADDING_CUSTOM_NODES.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 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
|
||||
115
browser_tests/tests/customNodes/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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.
|
||||
449
browser_tests/tests/customNodes/allNodes.spec.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/* 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 ?? ''})` : ''}`
|
||||
}
|
||||
115
browser_tests/tests/customNodes/autoRun.pure.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
478
browser_tests/tests/customNodes/connectivity.spec.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
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}`
|
||||
)
|
||||
}
|
||||
})
|
||||
58
browser_tests/tests/customNodes/coreSmoke.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
191
browser_tests/tests/customNodes/customNode.regression.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/* 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()
|
||||
})
|
||||
29
browser_tests/tests/customNodes/manifest.pure.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
71
browser_tests/tests/customNodes/runResult.pure.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
139
browser_tests/tests/customNodes/typePairing.pure.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -28,12 +28,7 @@ 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'
|
||||
|
||||
// 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
|
||||
const BOOT_FEATURES = { team_workspaces_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.
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
@@ -46,7 +46,6 @@ 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)
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node keeps all errors visible and shows selection context', async ({
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -301,25 +301,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(
|
||||
strip,
|
||||
'The strip count is scoped to the selection, diverging from the global reference badge'
|
||||
).toContainText('1 error')
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(
|
||||
strip,
|
||||
'Deselecting swaps the always-visible strip back to the summary'
|
||||
).toContainText('2 nodes — 1 error')
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
@@ -392,7 +381,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node keeps all media rows visible and shows selection context', async ({
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
@@ -414,66 +403,13 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
|
||||
// Selection no longer filters the list — rows stay global and the
|
||||
// selection is surfaced via the context strip instead.
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(strip).toContainText('1 error')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Deselecting swaps the always-visible strip back to the summary
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection emphasis', () => {
|
||||
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_and_media'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
const mediaRow = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
|
||||
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
// The node sits near the canvas top where overlays intercept clicks
|
||||
await mediaNode.centerOnNode()
|
||||
await mediaNode.click('title')
|
||||
|
||||
// The unrelated missing-node group auto-collapses while the matched
|
||||
// media row stays visible and is marked as part of the selection
|
||||
await expect(missingNodeCard).toBeHidden()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('1 error')
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Emphasis ends: the collapsed group re-expands and the strip
|
||||
// returns to the workflow summary
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
@@ -691,8 +691,7 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -25,6 +25,8 @@ test.describe(
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await templateApi.mockThumbnails()
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
@@ -54,7 +56,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -99,7 +101,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -141,7 +143,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -182,7 +184,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -220,7 +222,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -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(ksampler.title, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |