Compare commits

..

94 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
b9ad300f18 detection-proof: move Vue-mount break to the widget pipeline - registry deletion self-heals via WidgetLegacy fallback (round-2 finding) 2026-07-08 21:40:50 -07:00
Nathaniel Parson Koroso
153dcbc495 detection-proof: move pack console break to onExecuted so the sweep reaches its CONNECT_REJECTED assert (row 4) 2026-07-08 20:54:11 -07:00
Nathaniel Parson Koroso
268cc9c11b detection-proof: narrow serialize break to Impact-prefixed nodes so the pack runtime raise (row 9) is reachable 2026-07-08 20:54:11 -07:00
Nathaniel Parson Koroso
033f7e6989 detection-proof: narrow mount break to Impact-prefixed nodes so the Vue-mount red (row 2) is visible 2026-07-08 20:54:10 -07:00
Nathaniel Parson Koroso
454f6f1de5 detection-proof: 90 min job budget - all-surfaces-broken run does ~7x a green run's work 2026-07-08 19:54:53 -07:00
Nathaniel Parson Koroso
b7af403cc0 docs(detection-proof): sync rows 8-10 to CI-step delivery (not forks) and all-live commit design 2026-07-08 19:17:54 -07:00
Nathaniel Parson Koroso
2b64f13edb detection-proof: break pack registration (Impact ImpactInt key rename) - CI-side, row 10 2026-07-08 19:06:22 -07:00
Nathaniel Parson Koroso
2c7ee46beb detection-proof: break pack runtime (WAS Constant Number raises) - CI-side, row 9 2026-07-08 19:04:46 -07:00
Nathaniel Parson Koroso
cc0cbb3e2e detection-proof: break pack console (Custom-Scripts showText.js) - CI-side, row 8 2026-07-08 19:01:21 -07:00
Nathaniel Parson Koroso
b5a8d1a147 detection-proof: break graph-load hook - extension throws on load 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
91a979ecba detection-proof: break execution - drops numeric widget inputs from the prompt 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
aae02ff355 detection-proof: break linking (drag-drop) - slot hit-test never resolves 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
c711ebce29 detection-proof: break linking (by type) - rejects all IMAGE connections 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
1470d96f2a detection-proof: break persistence - off-by-one drops last saved widget value on reload 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
55b06a319a detection-proof: break mount (v2 Vue renderer) - drops the int widget mapping 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
1152ac59a2 detection-proof: break mount (v1 litegraph renderer) - drops last declared input 2026-07-08 18:52:27 -07:00
Nathaniel Parson Koroso
baa2e046b7 docs(custom-nodes): Detection Proof accuracy pass
Pack-mode breaks are delivered via manifest pin swaps (CI clones packs at
pins, so in-repo pack-file edits cannot reach it); corpus-derived red
messages promise the tier and failure class, not byte-identical offender
text; remaining citations and mechanism descriptions tightened to what the
code and captured runs actually show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:19:11 -07:00
Nathaniel Parson Koroso
0cc21040c4 docs(custom-nodes): correct Detection Proof citations after a hallucination audit
Every quoted red message re-verified against its original run log (10/10
match). Seven citation corrections from the audit: the iTools missing-
button tickets are Nodes 2.0 regressions and move to the v2 mount row
(the v1 row is now honestly class-only); the SAM3 hidden-values tickets
are removed (extras-exposed class, which the mount tier tolerates by
design, so citing them overclaimed coverage); the persistence row now
cites the verified defaultInput migration regression (widgets reverting
to socket-only on reload) that open PR #12279 fixes, instead of a live
widget-interaction ticket; the links-type and serialization rows drop
borrowed tickets and state their class plainly; the drag row's tickets
are labeled nearest-symptom family; the two expansion bullets now cite
the committed pure-spec catches instead of an unproven live-sweep catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:46:21 -07:00
Nathaniel Parson Koroso
d65d853227 test(custom-nodes): per-test backend isolation via an afterEach drain to idle
Every test gets a fresh page but all tests share one backend, locally and
on CI alike (the CI job is deliberately unsharded). A test ending while
its prompt still executed left that work running, and the next test's
fresh page connected mid-execution and inherited its async errors or its
busy queue. Drain the backend to idle in an afterEach in all four
backend-running specs, while the finishing test's own page is still open
so late events land there: no test can affect the next.

The drain helper moves to the shared fixture util (drainBackendToIdle,
byte-identical body); the auto-run tier's queue guard and runBatch
post-timeout drain rewire to it with their explicit budgets. The hooks
use a 10s budget: a no-op when already idle, and a backend still busy
past it is wedged, which the auto-run tier's 150s guard surfaces with
the restart diagnostic.

DETECTION_PROOF.md's caveat is rewritten to match, and a false claim
that CI shards one backend per pack is corrected in every location
(code comments and doc): the CI job runs the whole suite against one
fresh backend on an unloaded runner, which is why executions stay
inside their budgets there.

Empirical: a full-suite run with the hook eliminated the cross-test
bleed class entirely (zero mount/save-reload/core-smoke console or
overlay failures, previously 3-5 per run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:14:14 -07:00
Nathaniel Parson Koroso
08106db082 test(custom-nodes): make the local full run idempotent (drain, foreign-noise filter, slow-node budgets)
The suite runs all 7 packs' execution tiers against one shared backend
locally (CI shards one backend per pack). Serial execution created three
distinct cross-test contaminations that failed a different set of packs
each run; each is now fixed at its mechanism:

- Foreign execution noise: mount/persistence/wiring/T0/core-smoke tiers
  queue no prompts, yet caught a prior tier's async execution error
  (PromptExecutionError, a 400 on /api/prompt). isForeignExecutionNoise
  filters execution-domain console lines from the non-executing tiers
  only; the executing tiers still assert them. Same "not this test's
  evidence" principle as event attribution (ARCHITECTURE section 9).
- Queue contention: the auto-run queue-busy guard hard-failed when a prior
  pack's slow CPU execution was still draining. drainUntilIdle waits it
  out (interrupt + clear + poll, throw-on-error so a failed read counts as
  busy); only a genuinely wedged backend fails. runBatch's post-timeout
  drain grows from 5s to 90s for the same reason.
- Slow-under-load misread as a regression: the single-node disambiguation
  re-run gets 60s instead of the batch's 20s. A real hang still exceeds it.

Also excludes the CLIPSeg model loaders (essentials, WAS) - model-download
nodes, same non-interruptible class as the listed BLIP/SAM/MiDaS loaders.
Reviewed by four-hat CORE (ship it); the new predicate is unit-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:22:11 -07:00
Nathaniel Parson Koroso
10d7769ab5 docs(custom-nodes): neutral phrasing in Detection Proof (drop first-person reference)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:32:41 -07:00
Nathaniel Parson Koroso
349d82b63e docs(custom-nodes): add Detection Proof plan (falsify every guard, correlate to real regressions)
The correlation matrix + throwaway-PR plan that proves the suite catches
every failure mode ARCHITECTURE.md claims: one deliberate break per
surface, each citing the real historical regression it recreates (Linear
Custom Node Bugs issues + FE PR #12279) and the exact CI red it produces.
Every "exact red" is captured from a real falsification run, not a
prediction. Renames the earlier "kill-test" work to the falsification
pass. States the honest local-full-run idempotency caveat (CI shards
per-pack; a single-backend serial run is not the oracle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:32:00 -07:00
Nathaniel Parson Koroso
6be7c18bef test(custom-nodes): sharpen three detection surfaces the break-suite exercise found
- Wiring drop resolution: the curated drag test only targeted first-slot
  inputs, so a slot hit-test regression that falls back to the first
  compatible input went undetected. Add a second-slot anchor
  (EmptyImage.IMAGE -> ImageBatch.image2) that only links if the drop
  resolves the exact slot; proven by breaking getNodeInputOnPos.
- Curated-run failure naming: a backend validation rejection answers
  /prompt with node_errors but app.queuePrompt swallows it, so a
  VALIDATION_FAIL reported {}. Capture and flatten the node_errors
  (summarizePromptError, typed off apiSchema PromptResponse) into the
  result's clientError and surface it in the T1 message, so a red names
  the node and input. Exported with a pure unit test since the happy path
  never runs it.
- Console-error window: document (README + ARCHITECTURE section 10) that
  the ledger collects per-tier, so boot-time pack console noise before the
  first tier action is out of scope by design, backstopped by the startup
  zero-visible-errors check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:27:07 -07:00
Nathaniel Parson Koroso
58e0fe7511 docs(custom-nodes): section 6 shows the census feeding three parsers; onboarding mirrors the scoped invariant
Round-3 review follow-ups: the definition-pipeline diagram no longer
implies a centralized normalizer (live census -> wiring slot normalizer /
execution classifier / mount declared-shape parser, matching section 4),
and ADDING_CUSTOM_NODES scopes the zero-visible-errors claim to the tiers
that assert it, same wording as the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:22:05 -07:00
Nathaniel Parson Koroso
72ad08db63 test(custom-nodes): shared console ledger, drift guards, and a doc truth pass from round-2 review
- Extract the pack console-error allowlist into a shared fixture
  (consoleErrorLedger.ts); the curated T1 run now collects console and
  page errors across load+run and asserts them through the ledger. The
  filter is pinned by a discriminating pure spec (pattern match,
  cross-pack ownership, unknown-pack fail-open).
- T1 asserts every expectedNodes type is actually present in the curated
  workflow before running it, killing the vacuous-green path where a
  drifted fixture shrank the executed-set check to an empty id list.
- typePairing records unrecognizable slot specs (unknownSlots on the
  node, unknownShapes on the plan) instead of silently dropping them;
  connectivity logs the list; pure tests pin the input/output drop paths
  and the socketless boundary.
- Add test:custom-nodes:ci, the gate-equivalent run against the
  backend-served built frontend; README re-scopes test:custom-nodes as
  the dev-server loop that is NOT the gate, and scopes the
  zero-visible-errors invariant to the tiers that hold it.
- ARCHITECTURE truth pass: event attribution leads with the positive
  prompt-id capture; section 10 grades ledger guards in three strengths;
  the decentralized parser story (declaredShape, classifyInput,
  normalizer) is stated consistently in section 4, gotcha G5, and the
  legend; ADDING_CUSTOM_NODES points the console ledger at its new home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:43:42 -07:00
Nathaniel Parson Koroso
24659c2caf test(custom-nodes): implement 8 external-review hardenings with discriminating self-checks
- positive prompt-id attribution: capture the /prompt response id as the
  primary event filter (seen-set + graph membership stay as depth); a new
  attribution self-check injects a foreign-prompt terminal error mid-run
  and proves it cannot fail the run
- console collection now includes pageerror (uncaught exceptions and
  rejections), with a collector self-check as positive control; surfaces a
  real Custom-Scripts betterCombos typeof-null bug, ledgered with mechanism
- connectivity allowlists are two-way stale-guarded: every entry must be
  observed failing in its recorded way, all stale keys reported per run
- manifest pins are required full 40-hex SHAs (CUSTOM_NODES_ALLOW_UNPINNED=1
  admits only empty pins, reserved for the planned pack-HEAD canary); pack
  must be a plain path segment; contract pinned by pure specs
- CI installs each pack under custom_nodes/<pack> with charset and pin
  gates before cloning (attribution keys on the install dirname)
- allNodes renderer loops honor rendererPassesFor (vueNodesCompatible)
- curated T1 asserts every display sink emitted a ui payload; console
  sinks documented as excluded (no ui payload by design)
- the always()-wrapper suggestion was rejected on sibling evidence:
  ci-tests-unit.yaml gates its required check with a changes job and
  job-level if, and no repo workflow uses a wrapper

Reviewed via ninja pipeline: 4-hat CORE panel (2 passes), senior QA gate
(2 rounds, discrimination proven by falsification), gated review (Primary,
Double Checker, Ultimate Skeptic - 15-entry evidence ledger, all PROVEN).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:34:08 -07:00
Nathaniel Parson Koroso
e46487f3ed docs(custom-nodes): regression-suite title, tier gating truth, verified stack claims
Implements the cold insider review's full finding set plus pipeline
verification fixes:
- title gains 'regression', matching every sibling doc
- wiring tier renderer coverage told truthfully (breadth sweep one,
  curated drags both); decision 7 enumerates all renderer surfaces
- section 5 gains the manifest tiers vocabulary bridge (run and
  connectivity gate; load and io currently gate nothing)
- renderers named once (LiteGraph / Vue Nodes 2.0); opener deduplicated
  against section 1; implementation map matches the real manifest schema
- section 13 names Playwright, bundled Chromium, GitHub Actions, with a
  caveated runtime ballpark; gotcha receipts carry only verifiable claims
- sections 10 and 13 diagrams conform to the doc's diagram grammar
- ADDING_CUSTOM_NODES tiers gloss and manifest.ts workflow comment
  aligned with the same gating reality

Reviewed via ninja pipeline: 4-hat CORE panel (2 passes), senior QA gate
(2 rounds), gated review (Primary, Double Checker, Ultimate Skeptic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:40:13 -07:00
Nathaniel Parson Koroso
3bc40bb148 docs(custom-nodes): legend reads in ascending section order per depth; name the map's ordering principle
Dagre's crossing minimizer ignores edge declaration order, so the fix is
node declaration order. Also states explicitly that the map is ordered by
zoom, not page order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:30:14 -07:00
Nathaniel Parson Koroso
cbceaf5dd9 docs(custom-nodes): context + execution + persistence diagrams restructured per review
- context diagram flows one way: driver -> frontend -> verdict synthesis -> team
- execution flow: classification fans out to its three verdicts; runnable paths
  converge on batching, blocked routes straight to reconciliation
- persistence check: sequence diagram replaced with a linear pipeline (one
  actor issuing commands is a procedure, not a message exchange)
- building blocks: tiers fan 2x2 inside the horizontal pipeline
- tripwire step + small recovers? diamond instead of one giant diamond

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:31:15 -07:00
Nathaniel Parson Koroso
497dd6ede5 docs(custom-nodes): building-blocks view horizontal, raise label wrap width
The building-blocks pipeline rendered as a tall narrow strip for two
mermaid reasons: labels auto-wrap at the ~200px default regardless of
line length, and a subgraph's declared direction is ignored once it has
external edges, so the tier row silently stacked vertically. The view
is now a left-to-right pipeline with the tier group in the middle, and
the wrap-width directive makes boxes wide instead of tall here and in
the definition-pipeline and execution-flow views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:15:08 -07:00
Nathaniel Parson Koroso
cec09da789 docs(custom-nodes): close the observation loop in the context view
The frontend box was a dead end: the suite drove it but nothing flowed
back, so the verdicts arrow to the team looked sourceless. Added the
return edge (observations back: what mounted, what persisted, what
executed, every error) and reworded the team edge so verdicts are
visibly the synthesis of those observations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:13:16 -07:00
Nathaniel Parson Koroso
9eb035e8a8 docs(custom-nodes): drop the redundant D-diagram labels; de-jargon the batching box
Headings were double-numbered ("2. D1 - system context") with an
internal diagram-numbering scheme that means nothing to a reader.
Sections are already numbered: headings now just name the view, and
every cross-reference points at a section. Also replaced "queue cost
is amortized" with plain English: one submission carries many nodes
instead of paying the round-trip per node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:08:53 -07:00
Nathaniel Parson Koroso
3cef258bac docs(custom-nodes): wide diagram boxes - strip forced line breaks from D1-D4
Reader feedback on the rendered views: manual line breaks inside boxes
force Mermaid to render narrow, tall boxes with heavy wrapping, so the
diagrams cost too much scrolling. Mermaid sizes a box to its longest
line, so the fix is one or two long lines per box with elaboration in
the prose below the diagram. Applied to D1 (context), D2 (pipeline),
D3 (definition pipeline), and D4 (execution flow); D7 and D8 stay as
approved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:06:01 -07:00
Nathaniel Parson Koroso
34faaa1a1d docs(custom-nodes): redraw D2/D3 for single-flow readability
Reader feedback on the rendered views: D2's service-to-tier arrows
crossed the whole diagram with ambiguous fan-ins (three unattributed
"pass/fail + exceptions" curves), and D3's corpus box mixed the
two-dialects annotation into a flow node right where three arrows fan
out, reading as if the dialects explained the fan-out.

Fixes, using the rules that make the CI view work: one direction of
flow per diagram, no many-to-many edges (the service-to-tier matrix is
now a table, which is what a matrix is), and annotations live in prose
rather than inside flow boxes. D2 is now a straight
manifest -> orchestrator -> tiers -> evidence -> verdict pipeline with
a three-row shared-services table; D3 moves the dialect fact into the
normalize step, labels the fan-out "derives", and adds one sentence
mapping each derived plan to its consuming tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:03:02 -07:00
Nathaniel Parson Koroso
95ced4c6ef docs(custom-nodes): ARCHITECTURE.md - C4-style views, design decisions, gotchas reference
Architecture documentation for the custom-node regression suite,
written as design views rather than an implementation dump:

- eight responsibility-level views: system context, building blocks,
  the node-definition pipeline, the execution flow, the persistence
  check, event attribution, the evidence model, and the CI deployment
  view; every diagram box names a responsibility or concept, arrows
  carry meaning, and decision points read in plain English
- a one-minute What/Why/How opening with the three explicit non-goals
  (output semantics, frontend-virtual nodes, hour-scale soak) and a
  clearly labeled scale snapshot so instance numbers never read as
  properties of the design
- a 12-row design-decisions table with honest trade-offs (why a real
  browser at all, why the backend serves the built frontend, one
  worker, disabled execution cache, pinned pack versions, one-row
  extensibility, per-tier renderer policy, mechanism-carrying
  exceptions, the two-way baseline, batch+bisect, and the scope line),
  plus the curated-workflow fixture named as the deliberate extension
  seam
- a 14-item gotchas reference, each entry in symptom / root cause /
  defense / which-team-concern-it-answers form, with named nodes kept
  only as worked examples of their class
- one implementation map section where architecture names meet code
  symbols, covering every building block including the orchestrator
  and the evidence ledgers
- the workflow's rotted sharding comment fixed (suite duration and
  the real shard trigger)

Grounded on the C4 model's published guidance, reviewed by an
independent architect pass (two view-coherence gaps found and fixed)
after three earlier Opus review passes on content accuracy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:08 -07:00
Nathaniel Parson Koroso
f9a94d0296 docs(custom-nodes): ARCHITECTURE.md - system design, data flow, and the incident behind every invariant
The suite had run/onboarding docs but nothing describing the SYSTEM:
what the pieces are, how node definitions flow through the planners and
classifiers, how the execution harness attributes outcomes, and why each
non-obvious rule exists. This adds the missing third doc with four
Mermaid box-line diagrams (system overview, def data flow, run pipeline,
CI pipeline), the tier-by-renderer coverage matrix, the full ledger
table with the two-way baseline semantics, and the hard-won invariants
each tied to the incident that forced it (widgetValueStore id bleed,
event cross-attribution, pack JS queue-hook crashes, Vue effect timing,
queue-jam tripwire). Scope contract is stated up front: compatibility
and regression gate, not a behavior certifier.

Every path, symbol, and number cross-checked against the tree before
commit. README and ADDING_CUSTOM_NODES now cross-reference it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:38:37 -07:00
Nathaniel Parson Koroso
e7681be896 test(custom-nodes): shape-census audit - classify V2-form combos, forceInput beats every form, census-derived fixtures
Systematic audit for siblings of the two combo bug classes (ungrounded
contract, shape blindness), driven by a live shape census of the exact
getNodeDefs object the suite consumes:

- classifyInput now handles the V2 schema form (string 'COMBO' with
  options in the opts object; 495 such inputs exist in the transformed
  defs): options present = widget, empty or remote/lazy = NEEDS_MODELS.
  Real effect measured: 8 KJNodes nodes were silently misclassified
  NEEDS_WIRES and never executed - 5 now run clean, 3 correctly land in
  NEEDS_MODELS (remote combos).
- forceInput now beats every input form, list-form combos included (a
  census-found form the old branch order classified as widget; today's
  4 instances are optional or non-manifest, so this is protection, not
  a behavior change).
- pure-spec fixtures for both parsers now include every census form,
  copied from real census examples (V2 options, V2 empty, V2 remote,
  forceInput-on-combo, cross-form vocabulary pairing) so fixtures can
  no longer self-confirm the parser's assumptions.
- ADDING_CUSTOM_NODES.md gains the evidence rules: independent-oracle
  grounding for semantic claims, shape-census-driven parsing with
  exclude-with-record on unknown shapes, and verify-against-the-source-
  the-code-consumes.

defaultInput checked against frontend source: deprecated and ignored
(nodeDefStore warning only) - deliberately not handled.

Local verification: full customNodes suite 72/72 under CI parity; lint,
format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:30:51 -07:00
Nathaniel Parson Koroso
70233bbd04 test(custom-nodes): combo vocabulary is a set, not a sequence; never pair combos with unknown options
A wired combo input bypasses its own widget, so menu order and the
options[0] default are not part of the wire contract - membership is
(backend validation checks value-in-options). Vocabulary fingerprints
are now order-insensitive (sorted, element-wise canonicalized). In the
current corpus this changes zero pairs (measured: no same-set,
different-order combos exist across the 7 packs); the rule is now
correct for packs where they do.

Auditing that change surfaced a real hole: the frontend's transformed
defs present some combos as the literal string COMBO with options in
the opts object. The old fingerprint hashed all of those identically,
silently cross-pairing dropdowns with no vocabulary evidence - exactly
the checkpoint-into-scheduler class the combo rule exists to exclude.
Normalization now pulls V2-form options, and a combo with no known
option list is excluded from pairing instead of blind-matched. Plan
moves 5,058 -> 5,030 pairs; the 28 removed were vocabulary-blind.

Also from CI: MiDaS Mask Image excluded (torch.hub download inside
execute hung the Linux runner; runs clean only where the hub cache is
warm) and ImageTransformKJ ledgered (pack JS initializes its
fill-options JSON widget on configure).

Local verification: full customNodes suite 68/68 twice, lint, format,
knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:01:32 -07:00
Nathaniel Parson Koroso
a22acc4f48 test(custom-nodes): save/reload under both renderers; classify client-side queue throws; scope decisions written in-code
Coverage-gap audit after the mount-fidelity miss (assertions silently
narrower than their claim). Fixes:

- save/reload now runs under BOTH renderers with staged evaluates and
  frame yields, so Vue component mount/configure effects actually flush
  before each serialize - the one renderer-dependent value path a
  LiteGraph-only pass could not see. Console errors are now collected
  during the tier too (configure-time pack JS noise was uncovered).
- queuePrompt is wrapped in-page: pack JS that THROWS mid-graphToPrompt
  (VHS applyToGraph crashed CI's whole VHS tier) now classifies as
  VALIDATION_FAIL carrying the exception text, so the offender
  self-identifies instead of aborting the tier. VHS_SelectLatest
  excluded with that mechanism: its applyToGraph assumes downstream
  inputs have widgets and hard-crashes when its output feeds a pure
  socket while the input dir has matching files (upstream-report
  candidate).
- pack-owned-value nodes (ROUNDTRIP_VALUE_ALLOWLIST) no longer receive
  set-and-stick probe writes - writing `_cn` markers into editor JSON
  widgets just made pack JS choke on our own probes.
- deliberate scopes are now stated where the assertion lives: auto-run
  runs single-renderer because execution is a backend contract and
  values flow through the same store in both renderers; it deliberately
  skips the zero-visible-errors check because it provokes expected
  failures; the connectivity breadth sweep is renderer-independent with
  the curated drag test covering both renderers; combo vocabulary
  matching is deliberately order-sensitive (option order defines the
  default).

Local verification: full customNodes suite 67/67 under CI parity, plus
lint, format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:31:43 -07:00
Nathaniel Parson Koroso
e6ed6120a1 test(custom-nodes): assert def-vs-instance mount fidelity under both renderers; reconcile Linux CI flips
Mount fidelity now has a renderer-independent bar: under BOTH the
LiteGraph and Vue passes, every created instance must materialize
everything its def declares - each non-socketless input exists as a
widget or a socket (autogrow templates count via their dot-qualified
expansion slots, e.g. variables.a/variables.b), and every declared
output exists. The Vue pass keeps its extra layer: the DOM must render
at least the instance's widget and slot counts. Verified against all
823 nodes under both renderers; the only def-shape special case found
was the core autogrow container semantics.

Also reconciles the first Linux CI run of the chain-builder tier:
- environment flips move to AUTO_RUN_EXCLUDE with mechanisms and leave
  the baseline: Image Analyze, Text Parse A1111 Embeddings (fail macOS,
  clean Linux), Image Crop Face (clean macOS, AttributeError Linux),
  ImageReceiver (av decode error macOS, clean Linux)
- run-to-run flip-floppers excluded: ImpactRemoteInt,
  ImpactSchedulerAdapter, ImpactQueueTriggerCountdown (queue-hook JS
  transient refusals), LoadText|pysssss (state-dependent file combo)

Local verification: allNodes 21/21 twice consecutively, plus lint,
format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:00:17 -07:00
Nathaniel Parson Koroso
5ce414653e test(custom-nodes): chain-run NEEDS_WIRES nodes, assert outputs, mount fidelity, widget round-trip, combo pairing
Executes ~340 more nodes and hardens every tier's assertions:

- CHAINABLE verdict: required sockets with a model-free producer
  (EmptyImage, EmptyLatentImage, SolidMask, Primitive*, EmptyAudio) are
  synthesized and wired automatically; NEEDS_WIRES now means only truly
  unproducible types (MODEL, SEGS, CONDITIONING...)
- auto-run asserts data flow: every PreviewAny sink must emit a ui
  payload (NO_OUTPUT class); OUTPUT_NODE targets stay event-covered
- Vue mount asserts DOM widget/slot counts (missing fails, extras and
  in-row control_after_generate tolerated)
- save/reload is now two passes: pristine (reload must never shrink a
  node or change a value - the "widgets disappear" bug class) and
  set-and-stick (every plain widget holds a programmatic non-default
  write and it survives reload where topology is stable)
- connectivity pairs COMBO slots on exact option-vocabulary match
  (+~120 pairs); mismatched vocabularies stay excluded by design
- harness invariants: node ids never reused within a page (the
  widgetValueStore keys state by node id and survives graph.clear(), so
  a reused id inherits stale widget values - core bug, reported
  separately), and run events are filtered by prompt id + graph node id
  membership so late websocket events or flap-retry double-queues can
  never pin one node's failure on the next
- new mechanism ledgers: WIDGET_SET_ALLOWLIST, ROUNDTRIP_VALUE_ALLOWLIST,
  MOUNT_WIDGET_ALLOWLIST, all stale-guarded; AUTO_RUN_EXCLUDE gains the
  observed offenders (rembg pip-install-at-execute, empty-find infinite
  loop, from_pretrained downloads, minutes-long per-pixel loops)
- manifest baselines reconciled against three observation runs; stale
  entries removed, real failures (missing optional deps, degenerate
  synthesized inputs, CUDA-only recorders) baselined

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:32:16 -07:00
Nathaniel Parson Koroso
c5de8d421d test: fix ImageGrabPIL pack attribution; exclude WAS ffmpeg path node
ImageGrabPIL is a KJNodes node and was ledgered under WAS, so its
exclusion never applied - moved to the right pack, and the auto-run
test now asserts every exclusion key is actually registered by its
pack so a wrong-pack entry fails loudly instead of silently doing
nothing. WAS Create Video from Path joins the exclusions with CI
evidence (ffmpeg discovery differs per host).
2026-07-07 10:43:57 -07:00
Nathaniel Parson Koroso
9bb0587ec5 Merge remote-tracking branch 'origin/main' into nathaniel/custom-node-e2e-suite 2026-07-07 10:24:36 -07:00
Nathaniel Parson Koroso
de23856742 test: exclude runtime model-downloaders and content-variable nodes from auto-run
The rebuilt environment surfaced the rest of the download class: WAS
BLIP/SAM/MiDaS model loaders all hang the queue in non-interruptible
weight downloads (and would mass-download on a networked runner), the
random.org node needs internet by definition, KJ LoadAndResizeImage and
WAS Create Grid Image follow input-dir contents, and Impact media
widgets preview values via root-relative URLs (console allowlist
widened). Every entry carries its mechanism; all keep mount,
save/reload, and connectivity coverage. 65/65 both environments.
2026-07-07 10:15:11 -07:00
Nathaniel Parson Koroso
650abec3ab test: fix browser typecheck errors; recalibrate exclusions for CI environment
typecheck:browser (which the lint job runs, unlike root typecheck)
caught an unused classifyInput param, a branded-NodeId lookup, and
api.interrupt's required argument. The first serialized CI run then
exposed environment-variable nodes: clean on one host, failing on the
other (screen capture with no X display, PIL screen grabs headless,
torch-stack RuntimeErrors that are macOS-only, state-dependent WAS
history). Those move from the cannotRunAlone baseline to
AUTO_RUN_EXCLUDE with per-node mechanisms so both environments stay
deterministic, and Impact's hardcoded example.png preview 404 joins
the scoped console allowlist.
2026-07-07 09:59:24 -07:00
Wei Hai
747f76db76 ci: auto-merge approved, green backport PRs (#13390)
## Problem
`pr-backport.yaml` opens each backport PR (labelled `backport`) and
calls `gh pr merge --auto --squash`. GitHub's `--auto` only takes effect
when the repository's **"Allow auto-merge"** setting is enabled — it's
currently off, so that call is a silent no-op (swallowed by its `|| echo
"::warning::…"`). The result: every backport PR sits unmerged until
someone manually clicks merge, even when it's already approved with
green checks.

## What this does
Adds `.github/workflows/backport-auto-merge.yaml`, which completes the
merge directly — a plain `gh pr merge --squash` (which does **not**
depend on the "Allow auto-merge" setting) — once GitHub reports the PR
ready to merge.

Ready = `reviewDecision == APPROVED` **and** `mergeStateStatus` is
`CLEAN` or `UNSTABLE`. `UNSTABLE` means the required checks passed but a
*non-required* check is still pending/failing — GitHub still permits
that merge, and gating on `CLEAN` alone would leave backports stuck
behind slow/flaky non-required checks (Socket, codecov, perf, storybook,
etc.).

**Branch protection stays the real gate.** The `core/**` / `cloud/**`
ruleset unconditionally requires an approval + the required checks and
can't be bypassed, and GitHub's merge API re-enforces it at merge time —
so this workflow can only ever finish a merge that already satisfies
those rules. The eligibility check just avoids pointless attempts.

## Design notes
- **Merges with `PR_GH_TOKEN`, not the default token**, on purpose: a
merge by the default `GITHUB_TOKEN` does not emit the `pull_request:
closed` event, which would silently starve `cloud-backport-tag.yaml` (it
creates the `cloud/vX.Y.Z` tag on that event).
- **Triggers:** review submission + check-suite completion (low
latency), plus a 30-min sweep as a backstop for cases the events miss.
- **Never checks out PR code** (no untrusted-code path); only reads PR
metadata via the API. `permissions` on the default token are read-only.
- **Idempotent, bounded merge loop:** treats an already-merged PR (e.g.
a concurrent run or a human) as success, so it won't post a false
failure comment.
- Leaves the existing conflict path in `pr-backport.yaml` untouched
(conflicts never create a PR, so there's nothing here to act on).

## Validation
YAML parses; `actionlint` (with shellcheck) and `zizmor` both clean (0
findings).

## Before relying on it
- Confirm the org allows this workflow to run/merge (Actions policy) —
the merge uses a PAT so it shouldn't depend on the "Actions can approve
PRs" toggle, but worth verifying.
- First real backport: confirm it merges on ready and that
`cloud-backport-tag.yaml` then fires and creates the tag.
2026-07-07 16:50:23 +00:00
Mobeen Abdullah
386460afef fix(website): center button labels by tuning ppformula-text-center (#13445)
## Summary

Vertically center button/badge/nav labels by tuning the shared
`ppformula-text-center` utility from `top: 0.19em` to `top: 0.1em`.

## The alignment issue

PP Formula (our brand font) has asymmetric vertical metrics: its caps
sit high in the line box, so a naively centered label looks too high.
`ppformula-text-center` compensates by nudging the label down with
`position: relative; top: <em>` (a purely visual shift, it does not
change the element's box, so button/badge sizes are unaffected).

The value was `0.19em`, which **over-corrected**: the glyph ink ended up
~1.4px **below** center on every button, so labels read slightly low.
Measuring the actual glyph ink (canvas `measureText`
`actualBoundingBox*`) showed ~**0.09-0.10em** centers uppercase labels;
`0.1em` lands the ink within ~0.1px of center.

## Why it's safe (verified)

This utility is used site-wide (Button, Badge, ButtonPill, ButtonMask,
BrandButton, nav triggers, section labels). Because it's a
`position:relative` nudge, there is **no layout/box-size change**
anywhere. I measured glyph-ink centering across **12 pages** (home,
cloud, cloud/pricing, download, careers, customers, demos, enterprise,
api, mcp, gallery, learning):

- Buttons/badges/pills went from ~1.37px low to **~0.11px** (centered).
- **Nothing regressed** (no element pushed too high).
- The handful of numeric "outliers" were `text-transform: uppercase`
measurement artifacts (source text with descenders that don't render);
confirmed visually as centered.

## Changes

- **What**: `apps/website/src/styles/global.css` —
`ppformula-text-center` `top: 0.19em` → `0.1em` (one line).

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:37:32 +05:00
Mobeen Abdullah
5cf647d183 feat(website): add GPT Image 2 to cloud page model list (#13431)
## Summary

Add GPT Image 2 to the `/cloud` "AI models" section, and apply the
design review polish from Bert and June on the same section and the
neighbouring cloud-page cards.

## Changes

- **GPT Image 2 card**: 6th card in `AIModelsSection` (workflow video on
`media.comfy.org`, OpenAI badge reused from `packages/design-system`),
new `cloud.aiModels.card.gptImage2` i18n key (en + zh-CN), and GPT Image
2 added to the `cloud.reason.2.description` partner-model list.
- **AI models layout**: the six cards are now equal 1:1 squares in one
shared grey container (was a per-card treatment, then simplified to a
single container per design), with a corner arrow affordance on each.
- **Audience cards** (`AudienceSection`): the creators / teams cards now
link to `cloud.comfy.org` with the same corner arrow (highlights on card
hover).
- **Reusable `CardArrow`**: extracted the corner arrow into a shared,
decorative (`aria-hidden`) component used by both sections;
`hover="group"` (card hover) for audience, self-hover for the model
cards so it doesn't double up with the provider badge.
- **`ProductCard` CTA**: swapped the hand-rolled pill `<span>` for the
shared `Button` (`as="span"`) so the label is vertically centered (fixes
Bert's off-centre text) without nesting an anchor inside the card link.

## Split out of this PR

- **Button label centering** (the global `ppformula-text-center` tweak)
→ separate PR #13445, so the site-wide change is reviewed in isolation.
- **Pricing banner frame fix** reverted here; it belongs in Michael's
upcoming pricing PR (team tier, edu billing, FAQ). `PricingSection.vue`
shows only an automatic Tailwind class-order reformat from the
pre-commit hook, no behaviour change.

## Review focus

- The single-container AI models layout and the `CardArrow` hover
behaviour (group vs self).
- `Button as="span"` inside the `ProductCard` link (avoids nested
`<a>`).

Linear: FE-423

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 20:19:54 +05:00
Mobeen Abdullah
fe1fc8baa6 fix(website): standardize favicon to square brand icon (#13467)
## Summary

Standardize the marketing-site favicons to the square, full-bleed brand
mark (ink background, yellow C) so each platform applies its own corner
mask instead of double-rounding a pre-rounded asset.

## Changes

- **What**: Replace three files in `apps/website/public/`:
- `favicon.svg` — was a 57 KB RealFaviconGenerator wrapper around an
embedded PNG; now a 1.2 KB vector of the square mark.
- `favicon-96x96.png` and `apple-touch-icon.png` — regenerated square.
The apple-touch icon previously had transparent rounded corners, which
iOS composites onto a white tile; it is now full-bleed.
- `favicon.ico` and the `web-app-manifest-*.png` files were already the
square mark, so they are left unchanged.

## Review Focus

- Favicons cache aggressively (browser + CDN, and these paths are marked
`immutable` in `vercel.json`), so verify the preview with a hard refresh
or a fresh profile.
- Part of the org-wide favicon standardization (FE-705). Companion PRs
update the workflows hub, docs, and registry favicons to the same mark.
Design direction (square, not rounded) confirmed by Bert.

## Screenshots

The before/after for each binary is visible inline in the Files changed
tab. Square ink + yellow C, no transparency, sharp corners.
2026-07-07 20:18:12 +05:00
Benjamin Lu
3e4dd59e5f feat(navigation): add opt-in strip-on-capture to preserved-query tracker (#13465)
## What

- `installPreservedQueryTracker` definitions accept an opt-in
`stripAfterCapture` flag: the marked keys are captured into the
sessionStorage stash and removed from the URL before the navigation
completes (single guard redirect at the decoded query-object level;
push/replace semantics are inherited from the original navigation, and
vue-router force-replaces the initial one).
- `preservedQueryManager` now captures the first non-empty string
element of repeated (array-valued) params instead of silently dropping
them.
- New real-router test suite for the tracker (createRouter +
createMemoryHistory, no router mocks), including a history-depth test
pinning the push/replace inheritance; manager tests extended for the
array/junk-value cases.

## Why

One-time secrets in query params (first consumer: desktop login codes,
GTM-93) must not linger in the visible URL, browser history,
`previousFullPath` redirects, or telemetry. Stripping after navigation —
what each loader does ad hoc today — leaves a window and forces
per-feature URL scrubbing; #13418 originally needed a hand-rolled
encoding-aware string parser in three places. Stripping at capture time,
at the decoded query-object level, makes the stash the only carrier and
lets vue-router round-trip the surviving params' encoding itself.

Capability only — no existing namespace opts in; behavior is unchanged
for all current definitions. `stripAfterCapture`'s contract is
documented on the option: strip-marked keys must never be read from
`route.query` by later guards or views; the stash is the only
post-capture source.

## Landing order

Independent of everything else; #13418 stacks on this branch.
2026-07-07 07:08:11 +00:00
Benjamin Lu
e25e0f2e16 refactor: simplify slot link drop finalization (#13471)
## Summary

Small cleanup in `useSlotLinkInteraction.ts`, no behavior change:

- Removed a duplicated `raf.flush()` in `finishInteraction` (it was
called twice back-to-back).
- Collapsed four single-line `attempt*` alias closures in
`connectByPriority` into a direct short-circuit chain, preserving the
same evaluation order:

  ```ts
  return (
    tryConnectToCandidate(snappedCandidate) ||
    tryConnectToCandidate(domSlotCandidate) ||
    tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
    tryConnectViaRerouteAtPointer()
  )
  ```

The closures added no behavior beyond renaming the calls (AGENTS.md rule
26), and `||` gives the same first-truthy-wins semantics as the previous
`if (attempt()) return true` ladder.

Verification not rerun after rebasing onto `Comfy-Org/main`; original
branch reported `pnpm typecheck`, `pnpm lint`, `pnpm format:check`,
`pnpm knip`, and the existing `useSlotLinkInteraction` unit tests
passing.

Link to Devin session:
https://app.devin.ai/sessions/1351ff5174494106a7a688777554f387
Requested by: @benceruleanlu

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-07 07:05:36 +00:00
Benjamin Lu
2ee91c30ee fix: report CLA check in merge queue (#13477)
Report the CLA Assistant required check for merge queue commits so
queued PRs do not wait indefinitely on a check that ran on the PR head.
2026-07-07 07:57:02 +00:00
Alexis Rolland
854770d305 ci: Update CLA workflow to build author-only allowlist (#13378)
## Summary

Update CLA workflow to build a dynamic `allowlist` that includes
everyone except the author of the PR. This relaxes the CLA signature
requirement so that it is limited to the PR author only. By signing, the
author confirms he gots approval from other contributors.

## Changes

- **What**: `cla.yml`

## Screenshots (if applicable)

<img width="1831" height="756"
alt="{B6F6C23D-EC2E-4BB3-A288-99B6087F4CAC}"
src="https://github.com/user-attachments/assets/62c04465-d1a3-4ddb-bfe7-950a29a802c4"
/>
2026-07-06 19:29:59 -07:00
Nathaniel Parson Koroso
b99100d0b4 test: un-export AutoRunClass (knip: consumers use AutoRunVerdict) 2026-07-06 17:12:24 -07:00
Nathaniel Parson Koroso
6d5bcb9e04 test: suppress deferred-await false positives in turnstile timer tests
vitest/valid-expect flags assertions stored before advancing fake
timers and awaited after - awaiting at creation would deadlock the
timer advance. The file is byte-identical to main; the warnings appear
because the type-aware lint toolchain moved. Suppressed per line with
the reason; also drops a scratch await added while chasing this.
2026-07-06 17:05:39 -07:00
Nathaniel Parson Koroso
0723702791 test: serialize CI workers, prune comment noise, clearer naming
The auto-run tier needs exclusive backend-queue access, so the CI job
now runs with workers=1 - parallel workers were interrupting each
other's executions and cross-attributing errors. Await the async
toHaveLength assertion the type-aware lint flagged, drop the
calibration measurement harness (one-shot scaffolding; the measured
constant keeps a provenance note), rename NO_SINK to
NO_OBSERVABLE_OUTPUT, and cut comments down to load-bearing WHYs.
2026-07-06 17:03:21 -07:00
Nathaniel Parson Koroso
9825047176 ci: pin every manifest pack to its verified commit
An unpinned pack means any upstream push can red the gating check for
every PR in the repo. Each row now pins the exact SHA the suite was
verified against locally (all tiers green, both environments); bumps
are deliberate, re-verified changes. Also records why the job is not
sharded yet: per-shard setup (~4.5 min of pack installs and backend
boot) dominates the ~5.5 min suite, so a prebuilt image comes first.
2026-07-06 16:33:53 -07:00
Nathaniel Parson Koroso
7adfaa9079 test: stabilize every-node auto-run across environments; document the tiers
The cannotRunAlone baseline (per pack, in the manifest) records nodes
that cannot execute standalone on a bare backend, asserted both ways so
entries cannot rot: an unlisted failure is a regression, a listed node
that runs clean must be removed. queuePrompt rejection is retried once
before classifying VALIDATION_FAIL - pack JS hooking the queue path can
refuse transiently, and the backend log proved several apparent rejects
never reached the server. Nodes whose execution depends on their own
pack JS preprocessing widget values (rgthree Power widgets, KJ editors)
are excluded unconditionally with the mechanism recorded, since whether
a page applies pack JS varies by serving setup; ML-session initializers
and unstable executed-set reporters join them. ADDING_PACKS and the
README document the every-node tiers and all five exception ledgers.

Verified 65/65 in both documented environments: dev server and
dist-serving CI parity, twice consecutively on the latter.
2026-07-06 16:16:51 -07:00
Nathaniel Parson Koroso
1e36107109 test: every-node coverage - mount, save/reload, connect, and auto-run for all pack nodes
All-nodes tiers discover each pack's full node list from the live
backend: chunked mount checks in both renderers (batch size 24, chosen
by the committed calibration tool), chunked save/reload round-trips,
a connectivity corpus widened from the curated sentinels to every
registered node, and an auto-run tier that classifies every node
(AUTO_RUNNABLE / NEEDS_WIRES / NEEDS_MODELS / NO_SINK) and executes the
runnable ones in batches with per-node bisection on failure.

Hard-won harness rules baked in: queuePrompt rejects classify instantly
as VALIDATION_FAIL instead of burning the timeout; a timed-out batch
interrupts and verifies the queue drained so one hung node cannot jam
every later run; a pre-flight queue check fails fast with the real
cause; and three reviewable exception ledgers carry reasons inline
(AUTO_RUN_EXCLUDE for runtime-downloaders like RemBGSession+, a scoped
console-noise allowlist for KJNodes' undefined-filename previews,
connectivity CONNECT_REJECTED/ROUNDTRIP_LOST entries for pack JS that
vetoes or drops links).
2026-07-06 16:16:23 -07:00
Nathaniel Parson Koroso
1d5514c90e docs: rename ADDING_PACKS to ADDING_CUSTOM_NODES
The doc onboards custom nodes; name it what it is.
2026-07-06 16:16:09 -07:00
jaeone94
2e4c9c6fdc style: use neutral credits info button (#13461)
## Summary

Replaces the Additional credits tooltip trigger with the shared Button
component so the icon renders with the neutral muted treatment used by
the rest of the UI.

## Changes

- **What**: Uses the shared Button component for the Additional credits
info action while preserving the existing tooltip and aria label.
- **Dependencies**: None.

## Review Focus

Confirm this remains a visual-only change scoped to the Plan & Credits
credits tile.

## Screenshots (if applicable)

Before
<img width="397" height="368" alt="스크린샷 2026-07-06 오후 11 30 38"
src="https://github.com/user-attachments/assets/9cda1aee-4dc2-4ce1-b001-29d0e09fc07d"
/>

After
<img width="374" height="355" alt="스크린샷 2026-07-06 오후 11 31 00"
src="https://github.com/user-attachments/assets/64d0b726-6031-42d4-84d7-35405150698c"
/>


## Testing

- `pnpm format`
- `pnpm lint`
- pre-commit hook: stylelint, oxfmt, oxlint, eslint, typecheck
- `pnpm test:unit
src/platform/cloud/subscription/components/CreditsTile.test.ts`
2026-07-06 14:42:49 +00:00
Mobeen Abdullah
7d2858e74b feat(website): add Supported Models link to nav and footer (#13432)
## Summary

Surface the supported-models catalog (`/p/supported-models`) from the
home page by adding a "Supported Models" link to the Products dropdown
and the footer.

## Changes

- **What**: Add a `nav.supportedModels` i18n entry (en `Supported
Models`, zh-CN `支持的模型`) and link it to the existing `routes.models`
constant in two places: the Products mega-menu Features column (between
Launches and Docs) and the footer Products column (after Comfy MCP).
Locale handling comes from `getRoutes`, so zh-CN resolves to
`/zh-CN/p/supported-models` automatically.

## Review Focus

- Placement is intentionally in **both** the nav and the footer (per
FE-1190). Fine to drop either, happy to adjust after review.
- Reuses the existing nav/footer link pattern, no new components or
styles, and no `new` badge (the page is not newly launched).
- Consumes `routes.models`, which was already defined but previously
unused.

FE-1190

## Screenshots

**Nav — Products dropdown, Features column**

_Desktop:_

_Mobile:_

**Footer — Products column**

_Desktop:_

_Mobile:_

Preview: https://comfy-website-preview-pr-13432.vercel.app
2026-07-04 09:33:26 +05:00
Robin Huang
b9a0ac0fed fix(manager): drop embed=true so the Cloud survey renders its dark theme (#13438)
Remove `embed=true`, so PostHog applies the survey's own dark appearance
and it renders correctly.

Verified live on testcloud that removing the flag fixes the styling;
`distinct_id` user linkage is unaffected.
2026-07-04 01:56:29 +00:00
AustinMroz
b61e54db3b Allow forcing icon display as mask or image (#13414)
- Adds support for forcing an icon to display as a mask or image with
`icon-mask` and `icon-image`.
- Updated the logic so that svg of a solid color (like the claude logo)
display as an image by default
- Update many svg to consistently use `currentColor` so that they still
function as masks by default
2026-07-04 00:04:56 +00:00
jaeone94
b51ea29074 test: clean up TemplateHelper route mocks (#13019)
## Summary

Follow-up draft PR for the CodeRabbit issues created from the #12999
review. This keeps the original stabilization PR merged as-is and moves
the non-functional TemplateHelper cleanup into its own small branch.

## Changes

- Extracted TemplateHelper route patterns into named module-scope
constants.
- Normalized the TemplateHelper route patterns to anchored regexes with
optional query-string handling.
- Extracted `mockCustomTemplates()` from `mockIndex()` and made `mock()`
register custom templates, core index, and thumbnails together.
- Added a private `registerRoute()` helper so every mocked route is
registered for teardown consistently.
- Simplified the fixed empty custom-template response to `body: '{}'`.
- Updated the cloud template filtering spec to use `templateApi.mock()`
instead of manually combining thumbnail and index mocks.

## Issues

- Closes #13014
- Closes #13016
- Closes #13017
- Closes #13018
- Related #13015: this PR normalizes the TemplateHelper route patterns
only. The broader fixture-wide route pattern convention cleanup remains
intentionally separate.

## Validation

- `pnpm exec oxfmt --check
browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/tests/templateFilteringCount.spec.ts`
- `pnpm exec eslint browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/tests/templateFilteringCount.spec.ts`
- `pnpm typecheck:browser`
- Pre-commit hook also ran `oxfmt`, `oxlint`, `eslint`, `pnpm
typecheck`, and `pnpm typecheck:browser` successfully.

Note: I attempted the targeted cloud Playwright spec locally with
`PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec
playwright test browser_tests/tests/templateFilteringCount.spec.ts
--project=cloud`, but the local 5173 app was not running with the cloud
distribution configuration, so the distribution-filter assertions failed
in the expected local/cloud mismatch way. This should be verified by
CI's cloud project.
2026-07-03 23:55:52 +00:00
Nathaniel Parson Koroso
61d1cbfdb0 docs: fix ADDING_PACKS extensions probe and misattributed CI triage
grep -c on the single-line /extensions JSON could only say 0 or 1; count
entries properly with the same python one-liner style the doc already
uses. The Step 7 triage claimed our CI failure was upstream drift; it was
the dev-server blindspot - reorder the advice to reproduce under 6b
before diagnosing.
2026-07-02 18:49:20 -07:00
Nathaniel Parson Koroso
14666b09c4 docs: fold 5-pack onboarding lessons into ADDING_PACKS
Detect frontend-JS packs at install time, split local verification into
the fast dev-server loop and the CI-parity dist run (required when the
pack ships frontend JS), spell out that workflow media paths resolve
against the backend's working directory, and add upstream-drift triage
for unpinned packs. Checklist updated to match.
2026-07-02 18:46:05 -07:00
Nathaniel Parson Koroso
efb0365bc3 test: make connectivity instance-aware; slot drags survive pack page chrome
Two CI failures with the 7-pack backend, both from pack frontend JS that
never loads under the Vite dev server (its /extensions list is core-only):

- rgthree's Seed rebuilds its declared seed input as a widget-only
  control, so the planned BatchCount+.INT -> Seed.seed pair has no socket
  on the instance. The sweep now classifies that as
  WIDGET_ONLY_ON_INSTANCE, logged and excluded like wildcards; a name
  missing from both slots and widgets still fails hard, and the drag test
  picks the first in-pack pair that materializes on real instances.

- rgthree's progress bar shifts the canvas element 16px down, and
  NodeSlotReference.getPosition returned canvas-relative coordinates, so
  every slot drag grabbed the node title instead of the slot dot. Slot
  positions now include the canvas element's page offset (a no-op when
  the canvas sits at 0,0).

Documents the dev-server blindspot and the CI-parity loop (build dist,
--front-end-root) in the suite README and ADDING_PACKS. Verified 36/36
green against both the dev server and a dist-serving 7-pack backend.
2026-07-02 18:42:48 -07:00
Nathaniel Parson Koroso
065bc0c336 test: fail manifest load when a run tier has no workflow
A run row with an empty workflow would skip locally and rely on CI's
skip gate to notice the lost coverage; enforce the documented contract
at load time instead.
2026-07-02 18:13:26 -07:00
Nathaniel Parson Koroso
1248c4628a test: onboard 5 packs (rgthree, essentials, KJNodes, Custom-Scripts, WAS) with vueNodesCompatible flag and ADDING_PACKS guide
Five new manifest rows, each covering load, connectivity, and run tiers
with hand-authored model-free workflows verified against a live backend.
New optional vueNodesCompatible manifest field: a pack proven unable to
mount under Vue Nodes 2.0 runs its LiteGraph assertions only - never a
test.skip, so the zero-skip CI gate stays honest. All five packs mount
under Vue Nodes 2.0 empirically, so no row sets the flag; the decision
helper is unit-tested instead. ADDING_PACKS.md is the authoritative
step-by-step onboarding process, validated against live /object_info.
Manifest rows now also fail fast on an empty repo field.
2026-07-02 18:05:20 -07:00
Nathaniel Parson Koroso
ee83d67834 test: single tier source of truth; fix skip diagnostic to find nested skips
Derive CustomNodeTier from the VALID_TIERS array (as const) so adding a tier
is one edit and the type/runtime lists can't drift. The forbid-skips
diagnostic now recurses the report and prints only specs that actually
skipped - the old dump printed every title and a single-level filter would
miss specs nested under describe() blocks (which the regression spec uses).
2026-07-02 16:07:04 -07:00
Nathaniel Parson Koroso
f63b7d866e ci: gate custom-node job with changes-filter, not trigger paths
A required check gated by a trigger-level paths filter never creates a check
run on a PR that touches none of those paths, leaving branch protection stuck
Pending. Move the gating to a job-level if via the changes-filter action (a
skipped job counts as passing), mirroring ci-tests-unit.yaml, so this can be
marked required without stalling docs-only PRs. Keeps the same-repo fork guard
in the same if.
2026-07-02 16:03:37 -07:00
Nathaniel Parson Koroso
068191ea47 test: harden custom-node CI and manifest per review
Security: the pack-install job now runs only for same-repo PRs and pushes, so
a fork PR can't point the manifest's repo URLs at attacker-controlled code
that the job would clone and pip-install. Fork PRs keep the env-agnostic
coverage via the main e2e shards.

Stability: pack requirements install under a pip constraint pinning the CPU
torch stack, so no pack can swap torch for a GPU/incompatible build on the
--cpu runner.

Correctness: manifest validation rejects unknown tier values (a 'connectivty'
typo would otherwise silently drop that tier's coverage). Connectivity's
'pack installed' predicate is extracted to one isEntryInstalled helper used by
both the breadth and drag tests.
2026-07-02 15:56:17 -07:00
Nathaniel Parson Koroso
07c4b230b2 ci: make the custom-node job gating - fail on pack-install error or any skip
A regression gate that lets a broken pack through as a skip is theater. Pack
clone/dependency failures now fail the job (array+loop instead of a
failure-swallowing jq|while pipe), and a post-run check fails the job if any
test was skipped - on this backend every tier is meant to run, so a skip
means a pack or devtools did not load. Drops the informational framing;
mark custom-nodes-e2e required in branch protection to block merges.
2026-07-02 15:36:19 -07:00
Nathaniel Parson Koroso
9ed51f1e4b ci: run the custom-node suite against a backend with the packs installed
Phase 5. A new informational (non-gating) workflow that reuses the repo's
setup-frontend/setup-playwright/setup-comfyui-server actions, then installs
every pack the manifest declares (jq loop over customNodeManifest.json, so a
new pack row installs itself with no workflow change) and boots ComfyUI with
--multi-user --cache-none before running browser_tests/tests/customNodes.

This makes the load and run tiers actually execute in CI instead of skipping
for want of the packs - the whole point of the suite. A pack whose deps fail
degrades to an honest skip rather than reddening the job.
2026-07-02 15:25:31 -07:00
Nathaniel Parson Koroso
4a91fa4849 test: name connectivity tests in plain language
T-conn was planning-doc shorthand for the connectivity tier; test titles and
logs now say connectivity outright so CI output reads without tribal
knowledge.
2026-07-02 14:11:56 -07:00
Nathaniel Parson Koroso
0991905a89 test: exclude COMBO literals from connectivity auto-pairing
CI caught what a pack-rich local backend masked: isValidConnection compares
only the string COMBO while every combo slot carries its own option set, so
the planner would wire a checkpoint dropdown into a scheduler dropdown and
call it proof, and combo outputs declare a non-string output_name whose
instance slot name never matches (DevToolsNodeWithOutputCombo failed 5
pairs on CI as SLOT_CONTRACT_MISMATCH). Combo slots are now recorded and
counted like wildcards instead of paired, the normalizer coerces slot names
to strings, and a pure spec locks both behaviors. Targeted fixtures remain
the way to cover combo semantics.
2026-07-02 14:10:37 -07:00
Nathaniel Parson Koroso
df6764762b test: make the custom-node suite work on multi-user backends
The Comfy.userId=default settings override broke every test on multi-user
backends (the repo's stated browser-test prerequisite): devtools
set_settings wrote to a user no session reads, so Comfy.TutorialCompleted
never landed, the templates dialog never opened, and the beforeEach wait
timed out - CI sessions even inherited leftover settings (a zh locale) from
earlier tests on the same worker user. Dropping the override lets the
fixture target the real per-worker user everywhere; the harness backend now
runs --multi-user like CI. Connectivity's per-pack guards and drag
derivation apply only to installed packs, so a backend without the manifest
packs reports the absence instead of hard-failing while the core sweep,
native drag, and self-checks still run.
2026-07-02 13:45:34 -07:00
Nathaniel Parson Koroso
2d2b318450 test: drop exports from internal-only custom-node types
knip flags exported types with no external consumers; CustomNodeTier,
ObjectInfoNode, NormalizedSlot, and SlotRef are referenced only within
their own modules.
2026-07-02 13:15:58 -07:00
GitHub Action
0f94da8746 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 19:32:18 +00:00
Nathaniel Parson Koroso
d80427d014 test: assert breadth-sweep console errors and tighten manifest shape checks
The breadth sweep now fails on any console error captured during the
connect/serialize/prompt loop, matching the fidelity test. The wildcard
predicate is exported from typePairing and reused instead of re-derived.
assertEntry validates real shapes (non-empty pack/expectedNodes/tiers,
arrays, boolean requiresGpu, finite positive timeoutMs); workflow stays
allowed as an empty string until a pack gains a run-tier fixture.
2026-07-02 12:28:23 -07:00
Nathaniel Parson Koroso
d02e665290 test: address review feedback on the custom-node suite
Resolve the manifest path from import.meta.url so tests are cwd-independent,
and validate requiresGpu at manifest load. Reuse the centralized TestIds for
the error overlay, error dialog, and templates dialog selectors. Extract the
shared suite settings and templates-dialog dismissal into
fixtures/utils/customNodeSuite so the three specs cannot drift. Rename
spikeDesktop.spec.ts to coreSmoke.spec.ts to match its maintained purpose,
document the full manifest schema in the README, and describe the gate
outcome without a hardcoded test count.
2026-07-02 12:24:33 -07:00
Nathaniel Parson Koroso
dc83cc4df6 test: prove the connectivity executor can reject, and drag every pack
A permanent self-check feeds the shared pair executor a type-incompatible
pair and a fabricated slot name and requires CONNECT_REJECTED and
SLOT_CONTRACT_MISMATCH back, so a green sweep can never come from a
classifier that lost the ability to fail. The breadth test asserts every
connectivity-tier pack contributes pairs, guarding pack attribution. The
drag tier's widget-primitive exclusion is removed: widget-backed inputs
render real slot dots under Vue Nodes (verified empirically), so every pack
now gets an in-pack drag in both renderers, asserted present.
2026-07-02 12:19:40 -07:00
Nathaniel Parson Koroso
8b81a4f359 test: add connectivity tier proving the slot/type contract
A type-pairing generator indexes /object_info producers and consumers and
plans one representative typed edge per slot, excluding wildcard slots
(isValidConnection short-circuits on * before the real type compare, so a
wildcard link proves reachability, not interop). The breadth sweep connects
every planned edge through the real validator in-page and requires each link
to survive serialize/configure and appear in graphToPrompt output; verified
up front that graphToPrompt emits links even when other required inputs
dangle. A curated subset is dragged slot-dot to slot-dot under both
renderers, addressed by data-slot-key so shared labels cannot misfire.
Orphan types are reported, never failed; connect vetoes must match a
committed allow-list. Manifest packs opt in via a connectivity tier that
needs no extra assets.
2026-07-02 12:14:39 -07:00
GitHub Action
8f567e8ef0 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 18:44:36 +00:00
Nathaniel Parson Koroso
4fb282f853 docs: link custom-node suite README from browser_tests README 2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
d17a387ddb test: name each custom-node check as a pnpm script and document the suite
One script per pack tier (impact-render/impact-run/vhs-render/vhs-run) plus
the self-check, all opening the Playwright Inspector so anyone can step
through what the robot does. README covers prerequisites, every script, a
worked example, the zero-visible-errors contract, and how to add a pack.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
68ba0aa613 test: add pnpm scripts for the custom-node suite
test:custom-nodes runs the whole suite headless (the gate); :watch opens a
headed slow-motion run of the browser tiers; :debug steps through them in the
Playwright Inspector. All target the local dev server on :5173 and use the
committed system-Chrome config (no bundled-chromium download). Pass -g to
:watch / :debug to run a single test, e.g. -g 'VideoHelperSuite.*T1'.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
675140c164 test: drop io tier scaffold until assertion-node pack exists
T2a gated on the ComfyUI-test-framework 'Assert Executed' nodes, which are
not published for any backend yet, so the tier could only ever skip. A test
that cannot run anywhere is reporting noise; restore it from history when
the assertion pack lands. Suite is now 16 passed, zero skips, zero failures.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
64706c53c3 test: enforce zero visible errors across custom-node suite
Every browser tier now asserts the app's user-facing error surfaces (error
overlay, error dialog, node render errors, error toasts) are absent at test
start and after each pass, so a run is green only if a human watching the
screen sees zero errors. The harness self-check asserts the overlay IS
visible after a forced execution error, keeping the selectors provably live.

Sessions boot with a blank graph (Comfy.TutorialCompleted=false) because the
bundled default template references models absent on a scoped backend; the
tutorial path's auto-opened template browser is dismissed per test. Settings
now reach the session on single-user server-storage backends by routing
devtools set_settings to the default user, and the errors tab stays enabled
so error indicators are never suppressed in this suite. The smoke test loads
a core-only model-free workflow instead of the SD1.5 default asset.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
bfa94d4118 test: enable run tier for Impact and VHS with model-free workflows
Impact runs ImpactInt and ImpactFloat into PreviewAny as a group; VHS decodes
the existing plain_video.mp4 asset through VHS_LoadVideoPath into
VHS_VideoInfo. The executed-set check asserts each expected node individually
executed, so group workflows still verify per-node execution. Requires a
cache-disabled backend (--cache-none) with the video staged in its input dir;
documented on the manifest workflow field.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
b7708d5ad0 test: validate timeoutMs and requiresModels at manifest load 2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
564de12d46 test: harden custom-node suite per review findings
Normalize the executing event tap: its CustomEvent detail is a bare node-id
string, so the previous object-spread left the executed-set permanently empty.
The self-check now asserts a non-empty executed-set to keep that path live.

T0 now clears the graph per renderer pass, asserts exact node counts, and
verifies each added pack node's own data-node-id mounts in the Vue pass
(default-workflow nodes can no longer satisfy the assertions). Console capture
starts before the renderer toggle. Added an object_info sanity floor so a
depleted getNodeDefs fails loudly instead of skipping everything.

Run/io tiers gain test.setTimeout, requiresModels gating, and an empty-workflow
guard. Interrupted runs get pure-spec coverage; the shared console collector
moves to fixtures/utils.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
5a1f788230 test: custom-node E2E regression suite (load/render, both renderers)
Data-driven Playwright harness verifying custom-node packs load and render
under both LiteGraph 1.0 and Vue Nodes 2.0 against a real ComfyUI backend.
Pure classifier/validator/manifest logic is unit-tested; the regression spec
renders each pack's nodes in both renderers (Vue via data-node-id DOM) and a
self-check runs a workflow to confirm execution-error capture. Proven against
Impact Pack + VideoHelperSuite.

Makes ComfyPage.createUser idempotent so the suite runs against a persistent
backend (Desktop server user storage).
2026-07-02 11:39:53 -07:00
183 changed files with 7642 additions and 2565 deletions

View File

@@ -0,0 +1,197 @@
---
name: Backport Auto-Merge
# Completes the merge of backport PRs once they are approved and their required
# checks pass.
#
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
# sit unmerged until a human clicks merge. This workflow performs the merge
# directly (a plain `gh pr merge --squash`, which does not depend on that
# setting) once GitHub itself reports the PR as ready to merge.
#
# Safety: branch protection on core/** and cloud/** is the hard gate — it
# unconditionally requires an approval + the required status checks and cannot
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
# workflow can only ever complete a merge that already satisfies those rules;
# the eligibility check below only avoids pointless merge attempts.
#
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
# performed by the default token does not emit events that trigger other
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
# backport PR's `pull_request: closed` event to create the release tag).
on:
# Fires when someone approves — if the required checks are already green, the
# PR merges immediately.
pull_request_review:
types: [submitted]
# Primary catch for the "approved first, checks went green later" case, plus a
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
# checks completing, but GitHub suppresses `check_suite` events for its own
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
# non-latency-critical task. Backports wait hours today, so a short sweep is a
# large improvement and needs neither.
schedule:
- cron: '*/15 * * * *'
# Only constrains the default github.token (used for read-only PR lookups below).
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
permissions:
contents: read # read-only; required for gh api / gh pr list to resolve candidates
pull-requests: read # read-only; required for gh pr view eligibility checks
# Serialize runs that act on the same PR (review events keyed by PR number; all
# scheduled sweeps share one key). Cross-key overlaps are still possible but
# harmless: the merge loop treats an already-merged PR as success (idempotent).
concurrency:
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
merge:
name: Merge eligible backport PRs
# Skip review events that can't possibly make a PR mergeable — non-approval
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
# spending any API call. Schedule sweeps always proceed. The per-PR
# eligibility checks in the job still re-verify the label and decision from
# live state.
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
runs-on: ubuntu-latest
permissions:
contents: read # read-only PR/commit lookups via the default token
pull-requests: read # read-only PR metadata via the default token
steps:
- name: Collect candidate backport PRs
id: candidates
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
numbers=""
case "$EVENT_NAME" in
pull_request_review)
numbers="$PR_FROM_REVIEW"
;;
schedule)
# Sweep every open backport PR.
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
--limit 100 --json number --jq '.[].number')
;;
esac
# De-duplicate and emit space-separated, digit-only tokens.
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
echo "Candidate PRs: '${numbers:-<none>}'"
- name: Merge eligible backport PRs
if: steps.candidates.outputs.numbers != ''
env:
GH_REPO: ${{ github.repository }}
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
READ_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
run: |
set -euo pipefail
is_merged() {
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
}
for pr in $CANDIDATES; do
echo "::group::PR #${pr}"
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
if [ -z "$info" ]; then
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
fi
state=$(echo "$info" | jq -r '.state')
is_draft=$(echo "$info" | jq -r '.isDraft')
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
base=$(echo "$info" | jq -r '.baseRefName')
review=$(echo "$info" | jq -r '.reviewDecision')
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
# Only ever act on open, non-draft, backport-labelled PRs targeting a
# protected release branch.
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
echo "::endgroup::"; continue
fi
case "$base" in
cloud/*|core/*) : ;;
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
esac
# Ready = approved AND GitHub says it's mergeable with required checks green.
# CLEAN = approved, all required checks green, mergeable, no conflict.
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
# still allows the merge, so we do too (matches what a human
# clicking "Squash and merge" can do; required checks are the
# only merge gate per the ruleset). Requiring CLEAN alone would
# stick forever behind flaky/slow non-required checks.
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
# by a later event or the next sweep.
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
echo "::endgroup::"; continue
fi
echo "PR #${pr} is ready — attempting squash merge."
attempt=0
max=3
merged=false
while [ "$attempt" -lt "$max" ]; do
attempt=$((attempt + 1))
# A concurrent run (or a human) may have merged it already.
if is_merged "$pr"; then merged=true; break; fi
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
merged=true; break
fi
echo "Merge attempt ${attempt}/${max} failed: ${out}"
# No sleep after the final attempt.
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
done
# Final reconciliation: a failed merge command may just mean a concurrent
# run won the race — don't post a false failure if the PR is in fact merged.
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
if [ "$merged" = "true" ]; then
echo "PR #${pr} merged."
else
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
# Avoid spamming a persistently-stuck PR: only re-warn if the last
# warning (identified by its marker) is more than an hour old.
marker='<!-- backport-auto-merge:merge-failed -->'
# `gh api --paginate` emits one JSON array per page; `--jq` would run
# per page (missing the true latest across pages), so slurp all pages
# into one array first and filter with a separate jq pass.
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
stale=true
if [ -n "$last_warned" ]; then
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
now_epoch=$(date -u +%s)
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
fi
if [ "$stale" = "true" ]; then
body=$(printf '%s\n\n%s' \
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
"$marker")
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
else
echo "Already warned within the last hour — skipping duplicate comment."
fi
fi
echo "::endgroup::"
done

View File

@@ -0,0 +1,214 @@
# 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
# Deliberately NOT sharded yet: the suite is ~8 min but every shard would
# pay the full ~4.5 min setup (clone + pip-install every pack + boot the
# backend), so 2 shards buy ~4 min of wall time for double the runner cost,
# with diminishing returns beyond that. Sharding pays once test time dwarfs
# setup time -
# first cut setup with a prebuilt image of the pinned packs, then shard if
# the job exceeds ~12 minutes.
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
# DETECTION PROOF (demo branch only): a green suite fits in 30, but with
# every surface deliberately broken the suite walks hundreds of failure
# paths (single re-runs, drains), so it needs far more headroom.
timeout-minutes: 90
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")
# Install under the manifest `pack` key, not basename(repo): node
# attribution keys on the install dirname via python_module, and
# the two only coincide by luck. Same charset the manifest loader
# enforces - belt for anything that bypasses it.
pack=$(jq -r '.pack' <<<"$entry")
if ! [[ "$pack" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "::error::unsafe pack name: '$pack'"; exit 1
fi
# The gate tests exactly what was verified: a full SHA pin is
# mandatory here, before anything installs. The planned canary
# (pack HEADs) is the only intended unpinned consumer and runs
# with CUSTOM_NODES_ALLOW_UNPINNED=1 through the loader instead.
if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::$pack: pin must be a full commit SHA (got '$pin')"; exit 1
fi
dir="ComfyUI/custom_nodes/$pack"
echo "::group::install $pack"
git clone --depth 1 "$repo" "$dir"
git -C "$dir" fetch --depth 1 origin "$pin"
git -C "$dir" checkout "$pin"
if [ -f "$dir/requirements.txt" ]; then
pip install -r "$dir/requirements.txt" -c /tmp/torch-constraints.txt
fi
echo "::endgroup::"
done
# DETECTION PROOF (rows 8-10): the pack-shipped bugs the suite is meant to
# catch live inside third-party pack repos, which CI clones fresh at their
# pins - a normal frontend commit cannot reach that code. This step, on the
# never-merge nathaniel/detection-proof branch ONLY, pokes one verified
# break into each cloned pack right after install, so the Pack-mode rows go
# red on real pack failures. Each break asserts it landed (grep) so a silent
# no-op cannot fake a pass. Do NOT port this step to any real suite branch.
- name: DETECTION PROOF - break packs (rows 8-10)
shell: bash
run: |
set -euo pipefail
# Row 8 (console/pageerror ledger, s10): Custom-Scripts showText.js
# logs a console.error when the node executes. onExecuted, not
# onConfigure: the wiring sweep configures nodes but queues no
# prompts, so an onConfigure error would red the sweep's console
# assert first and starve row 4's CONNECT_REJECTED signature.
# Expected: curated run (T1) red with `console errors during curated
# run` + the text + script URL.
f=ComfyUI/custom_nodes/ComfyUI-Custom-Scripts/web/js/showText.js
perl -pi -e "s/(onExecuted\?\.apply\(this, arguments\);)/\$1 console.error('DETECTION PROOF (row 8): pack showText.js onExecuted failure');/" "$f"
grep -q "DETECTION PROOF (row 8)" "$f" || { echo "::error::row 8 break did not apply"; exit 1; }
# Row 9 (execution runtime, s7): WAS `return_constant_number` raises
# on entry. Expected: auto-run tier red `Constant Number:
# EXECUTION_ERROR ... not in cannotRunAlone; a regression`.
f=ComfyUI/custom_nodes/was-node-suite-comfyui/WAS_Node_Suite.py
perl -pi -e 's/(def return_constant_number\(self, number_type, number, number_as_text=None\):)/$1\n raise ValueError("DETECTION PROOF (row 9): pack node runtime failure")/' "$f"
grep -q "DETECTION PROOF (row 9)" "$f" || { echo "::error::row 9 break did not apply"; exit 1; }
# Row 10 (registration sentinels, s5/s10): Impact renames the
# `ImpactInt` node-class key, so the node no longer registers under
# its expected class_type. Expected: the mount + curated tests that
# reference ImpactInt skip, and the "Forbid skipped tests" gate fails
# the job on `skipped != 0`.
f=ComfyUI/custom_nodes/ComfyUI-Impact-Pack/__init__.py
perl -pi -e 's/"ImpactInt": ImpactInt,/"ImpactIntDETECTIONPROOF": ImpactInt,/' "$f"
grep -q "ImpactIntDETECTIONPROOF" "$f" || { echo "::error::row 10 break did not apply"; exit 1; }
# 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: |
# workers=1: the auto-run tier needs exclusive backend-queue access;
# parallel workers interrupt each other's executions.
pnpm exec playwright test browser_tests/tests/customNodes/ \
--project=chromium --reporter=list,json --workers=1
# A skip here means a pack or devtools did not load: on this backend every
# tier is meant to run, so a skip is a gate failure, not an honest pass.
- name: Forbid skipped tests
if: always()
shell: bash
run: |
set -euo pipefail
skipped=$(jq '.stats.skipped' custom-nodes-results.json)
echo "skipped tests: $skipped"
if [ "$skipped" != "0" ]; then
echo "::error::$skipped test(s) skipped - a manifest pack or devtools failed to load; skips are not acceptable in the gating job"
# Recurse so specs nested under describe() blocks are found, and
# print only the specs that actually skipped.
jq -r '.. | objects
| select(has("title") and has("tests"))
| select(any(.tests[]?; .status == "skipped"))
| .title' custom-nodes-results.json | sort -u | head -40
exit 1
fi
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report-custom-nodes
path: playwright-report/
retention-days: 7
if-no-files-found: warn

View File

@@ -6,6 +6,7 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -17,13 +18,45 @@ 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.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
(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'
))
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -39,9 +72,10 @@ jobs:
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
allowlist: ${{ steps.allowlist.outputs.allowlist }}
# Custom PR comment messages
custom-notsigned-prcomment: |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 938 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,28 @@
<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>

View File

@@ -1,6 +1,8 @@
<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
@@ -28,11 +30,9 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<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"
>
<Button as="span" variant="default" size="sm" class="mt-4">
{{ cta }}
</span>
</Button>
</div>
</a>
</template>

View File

@@ -38,7 +38,8 @@ 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.mcpServer', locale), href: routes.mcp },
{ label: t('nav.supportedModels', locale), href: routes.models }
]
},
{

View File

@@ -1,7 +1,9 @@
<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 }>()
@@ -27,7 +29,7 @@ const cards = [
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
>
{{ headingParts[0]
}}<span class="text-white">{{
@@ -37,10 +39,11 @@ const cards = [
</h2>
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
<div
<a
v-for="card in cards"
:key="card.labelKey"
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
:href="externalLinks.cloud"
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
>
<img
:src="card.image"
@@ -51,23 +54,27 @@ const cards = [
/>
<div class="mt-8 p-6">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<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>
<h3
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ t(card.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
{{ t(card.descriptionKey, locale) }}
</p>
</div>
</div>
</a>
</GlassCard>
</section>
</template>

View File

@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
<div class="max-w-2xl">
<h2
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
>
{{ t('cloud.pricing.title', locale) }}
</h2>
<p class="text-primary-comfy-ink mt-4 text-base">
<p class="mt-4 text-base text-primary-comfy-ink">
{{ t('cloud.pricing.description', locale) }}
</p>
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
class="mt-4 text-base font-bold text-primary-comfy-ink"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<a
:href="getRoutes(locale).cloudPricing"
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"
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"
>
{{ t('cloud.pricing.cta', locale) }}
</a>

View File

@@ -6,6 +6,7 @@ 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:
@@ -14,11 +15,10 @@ 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,48 +32,45 @@ 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`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
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`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
objectPosition: 'center 20%'
badgeClass: `${badgeBase} rounded-2xl`
},
{
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`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
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`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
badgeClass: `${badgeBase} rounded-2xl`
},
{
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`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
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`
}
]
function getCardClass(layoutClass: string): string {
return cn(
layoutClass,
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
)
}
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'
</script>
<template>
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="rounded-4xl bg-white/8 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="getCardClass(card.layoutClass)"
:class="cardClass"
>
<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
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
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"
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
</div>
<p
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"
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"
>
{{ t(card.titleKey, locale) }}
</p>
<CardArrow
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
/>
</a>
</div>
</div>

View File

@@ -82,6 +82,7 @@ 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,

View File

@@ -932,9 +932,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 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, GPT Image 2 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 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
},
'cloud.reason.2.badge.onlyOn': {
en: 'ONLY ON',
@@ -996,6 +996,10 @@ 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': '探索最新模型工作流'
@@ -2187,6 +2191,7 @@ 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': '联系' },

View File

@@ -248,7 +248,7 @@
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.19em;
top: 0.1em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

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

View 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": 100 },
"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": [5, "fixed"]
},
{
"id": 2,
"type": "PreviewAny",
"pos": { "0": 340, "1": 60 },
"size": { "0": 220, "1": 60 },
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
}
}
],
"links": [[1, 1, 0, 2, 0, "INT"]],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,287 @@
import type { Page, Response } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
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
prompt_id?: string
output?: unknown
exception_type?: string
node_id?: string
node_type?: string
traceback?: string[]
}
const TERMINAL = [
'execution_success',
'execution_error',
'execution_interrupted'
]
// The /prompt rejection body is the apiSchema PromptResponse shape
// ({ error: string | {message}, node_errors: { <nodeId>: { class_type,
// errors: [{ details, message }] } } }). Flatten it to a single line naming
// the node class and the failing input so a VALIDATION_FAIL result is
// actionable instead of an empty object. Exported for a pure unit test: the
// happy path never runs it, so without a test a regression here would rot the
// diagnostic back to `{}` silently.
export function summarizePromptError(body: unknown): string | undefined {
const payload = body as Partial<PromptResponse> | null
if (!payload || typeof payload !== 'object') return undefined
const parts: string[] = []
const topError = payload.error
if (typeof topError === 'string') {
if (topError) parts.push(topError)
} else if (topError?.message) parts.push(topError.message)
for (const [nodeId, nodeError] of Object.entries(payload.node_errors ?? {})) {
const cls = nodeError.class_type || nodeId
for (const err of nodeError.errors ?? []) {
const detail = err.details || err.message
if (detail) parts.push(`${cls}: ${detail}`)
}
}
return parts.length > 0 ? parts.join('; ') : undefined
}
function toPromptEvent(raw: RawEvent): PromptEvent {
if (raw.type === 'executing')
return { type: 'executing', node: raw.node ?? null }
if (raw.type === 'executed')
return { type: 'executed', node: raw.node ?? null, output: raw.output }
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[]
graphNodeIds?: string[]
timeoutMs: number
}
): Promise<RunResult> {
// A prior run's terminal event can arrive after its sink was read (late
// websocket delivery, or a timed-out prompt finishing during this run).
// Remember every prompt id already observed and ignore its events here,
// so one node's failure is never attributed to the next node tested.
const seenPromptIds = await page.evaluate(
(types) => {
const sink = window as unknown as {
__cnEvents: RawEvent[]
__cnSeenPromptIds?: string[]
__cnTapInstalled?: boolean
}
const seen = new Set(sink.__cnSeenPromptIds ?? [])
for (const event of sink.__cnEvents ?? [])
if (event.prompt_id) seen.add(event.prompt_id)
sink.__cnSeenPromptIds = [...seen]
sink.__cnEvents = []
if (sink.__cnTapInstalled) return sink.__cnSeenPromptIds
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 }
)
}
)
return sink.__cnSeenPromptIds
},
['execution_start', ...TERMINAL, 'executing', 'executed']
)
// Positively identify THIS attempt: the /prompt POST response body
// carries the prompt_id the backend assigned. When captured it becomes
// the primary event filter; the seen-set above and the graph-membership
// check below stay as defense in depth (capture can lose a race with a
// transient refusal, and `executing` events carry no prompt id at all).
let capturedPromptId: string | undefined
// A backend validation rejection answers /prompt with a non-2xx body
// carrying { error, node_errors }. app.queuePrompt swallows it and just
// returns false, so without capturing it here a VALIDATION_FAIL result
// names nothing. Snapshot the failing node/input so the outcome is
// actionable instead of an empty object.
let capturedValidationError: string | undefined
const onPromptResponse = (response: Response) => {
if (response.request().method() !== 'POST') return
if (!new URL(response.url()).pathname.endsWith('/prompt')) return
response
.json()
.then((body: unknown) => {
const id = (body as { prompt_id?: unknown } | null)?.prompt_id
if (typeof id === 'string') capturedPromptId = id
if (response.status() >= 400)
capturedValidationError = summarizePromptError(body)
})
.catch(() => {
// a refused submission answers with a non-JSON or error body;
// the refusal path below already handles it
})
}
page.on('response', onPromptResponse)
const stopCapture = () => page.off('response', onPromptResponse)
// app.queuePrompt (NOT api.queuePrompt: that submits an empty prompt).
// false = validation reject (emits no events), but pack JS hooking the
// queue can refuse transiently - retry once; real rejects fail twice.
// Pack JS can also THROW mid-graphToPrompt on a graph shape it does not
// expect; catch in-page so one bad node classifies as VALIDATION_FAIL
// (with the exception text) instead of aborting the whole tier.
const queueOnce = () =>
page.evaluate(async () => {
try {
return await window.app!.queuePrompt(0)
} catch (error) {
// Never an empty string: an empty __cnThrew would nullish-coalesce
// wrong downstream and blank the VALIDATION_FAIL message.
return { __cnThrew: String(error) || 'pack threw an empty error' }
}
})
const refused = (
result: unknown
): result is false | { __cnThrew: string } =>
result === false ||
(typeof result === 'object' && result !== null && '__cnThrew' in result)
let queued = await queueOnce()
if (refused(queued)) {
await page.evaluate(
() => new Promise((resolve) => setTimeout(resolve, 250))
)
queued = await queueOnce()
if (refused(queued)) {
stopCapture()
return {
outcome: 'VALIDATION_FAIL',
executedNodes: [],
outputsByNode: {},
// A throw carries its own text; a bare `false` reject leaves only
// the backend's node_errors captured off the /prompt response.
clientError:
(typeof queued === 'object' ? queued.__cnThrew : undefined) ??
capturedValidationError
}
}
}
// The submission resolved, so the /prompt response is in flight or done;
// give its body-parse a bounded beat before snapshotting the id.
const captureDeadline = Date.now() + 2_000
while (capturedPromptId === undefined && Date.now() < captureDeadline)
await new Promise((resolve) => setTimeout(resolve, 50))
// A silent permanent miss would degrade every run to the legacy filters
// with no signal - make the fallback observable in the runner output.
if (capturedPromptId === undefined)
console.warn(
'[customNodes] /prompt response id capture missed; falling back to seen-set filtering'
)
await page
.waitForFunction(
([terminal, seen, graphIds, promptId]) => {
const events =
(
window as unknown as {
__cnEvents?: {
type: string
prompt_id?: string
node_id?: string
}[]
}
).__cnEvents ?? []
return events.some(
(event) =>
terminal.includes(event.type) &&
(promptId !== null
? event.prompt_id === promptId
: !(event.prompt_id && seen.includes(event.prompt_id)) &&
(graphIds === null ||
event.node_id === undefined ||
graphIds.includes(event.node_id)))
)
},
[
TERMINAL,
seenPromptIds ?? [],
opts.graphNodeIds ?? null,
capturedPromptId ?? null
] as const,
{ 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
stopCapture()
throw error
})
stopCapture()
const raw = (
await page.evaluate(
() =>
(window as unknown as { __cnEvents?: RawEvent[] }).__cnEvents ?? []
)
).filter((event) =>
// Positive id match when captured (events without a prompt_id - bare
// `executing` strings - stay, and graph membership still vets them);
// otherwise the legacy seen-set exclusion.
capturedPromptId !== undefined
? event.prompt_id === undefined || event.prompt_id === capturedPromptId
: !(event.prompt_id && (seenPromptIds ?? []).includes(event.prompt_id))
)
const timedOut = !raw.some((event) => TERMINAL.includes(event.type))
return classifyRun({
events: raw.map(toPromptEvent),
expectedNodeIds: opts.expectedNodeIds,
graphNodeIds: opts.graphNodeIds,
timedOut
})
}
}

View File

@@ -0,0 +1,165 @@
// Classifies which nodes can execute with no hand-authored fixture; the
// rest are recorded with the reason, never silently dropped.
import type { RawNodeDef } from '@e2e/fixtures/customNode/typePairing'
type AutoRunClass =
// Widgets cover every required input and a terminus exists.
| 'AUTO_RUNNABLE'
// Every required socket is synthesizable from a model-free producer.
| 'CHAINABLE'
// A required socket type has no model-free producer (MODEL, CLIP, SEGS...).
| 'NEEDS_WIRES'
// A required combo has zero options (empty model/file scan).
| 'NEEDS_MODELS'
// No outputs and not an OUTPUT_NODE - nothing the executor could watch.
| 'NO_OBSERVABLE_OUTPUT'
export interface RequiredSocket {
name: string
type: string
}
export interface AutoRunVerdict {
key: string
verdict: AutoRunClass
// Wire output 0 to PreviewAny (false = the node is its own terminus).
needsPreviewSink?: boolean
// CHAINABLE: sockets to satisfy from SYNTH_PRODUCERS, in declaration order.
requiredSockets?: RequiredSocket[]
reason: string
}
// Model-free producers for each synthesizable socket type. NUMBER is a WAS
// type with a WAS producer, so each entry is validated against the live defs
// before it counts as synthesizable.
export const SYNTH_PRODUCERS: Record<
string,
{ nodeType: string; outputIndex: number }
> = {
IMAGE: { nodeType: 'EmptyImage', outputIndex: 0 },
LATENT: { nodeType: 'EmptyLatentImage', outputIndex: 0 },
MASK: { nodeType: 'SolidMask', outputIndex: 0 },
INT: { nodeType: 'PrimitiveInt', outputIndex: 0 },
FLOAT: { nodeType: 'PrimitiveFloat', outputIndex: 0 },
STRING: { nodeType: 'PrimitiveString', outputIndex: 0 },
BOOLEAN: { nodeType: 'PrimitiveBoolean', outputIndex: 0 },
AUDIO: { nodeType: 'EmptyAudio', outputIndex: 0 },
NUMBER: { nodeType: 'Constant Number', outputIndex: 0 },
'*': { nodeType: 'PrimitiveInt', outputIndex: 0 }
}
const WIDGET_TYPES = new Set(['INT', 'FLOAT', 'STRING', 'BOOLEAN'])
type InputSpec = [unknown, Record<string, unknown>?] | unknown
function classifyInput(spec: InputSpec): 'widget' | 'socket' | 'empty-combo' {
const specArray = Array.isArray(spec) ? spec : [spec]
const rawType = specArray[0]
const options = specArray[1] as
| { forceInput?: boolean; options?: unknown }
| undefined
// forceInput beats every form, combos included: no widget materializes,
// so no default exists to run on - the input must be wired.
if (options?.forceInput) return 'socket'
if (Array.isArray(rawType))
return rawType.length > 0 ? 'widget' : 'empty-combo'
if (typeof rawType !== 'string') return 'socket'
if (rawType === 'COMBO') {
// Transformed (V2-schema) defs carry combos as the literal 'COMBO' with
// the option list in the opts object. No static list (empty, or a
// `remote` lazy combo) means the default value cannot be verified
// runnable at plan time - same bucket as an empty model scan.
return Array.isArray(options?.options) && options.options.length > 0
? 'widget'
: 'empty-combo'
}
return WIDGET_TYPES.has(rawType) ? 'widget' : 'socket'
}
function socketType(spec: InputSpec): string {
const specArray = Array.isArray(spec) ? spec : [spec]
return String(specArray[0])
}
export function classifyAutoRunnable(
key: string,
def: RawNodeDef & { output_node?: boolean },
synthTypes: ReadonlySet<string>
): AutoRunVerdict {
const sockets: RequiredSocket[] = []
for (const [name, spec] of Object.entries(def.input?.required ?? {})) {
const kind = classifyInput(spec)
if (kind === 'empty-combo')
return {
key,
verdict: 'NEEDS_MODELS',
reason: `required combo "${name}" has no options on this backend`
}
if (kind === 'socket') {
const type = socketType(spec)
if (!synthTypes.has(type))
return {
key,
verdict: 'NEEDS_WIRES',
reason: `required input "${name}" (${type}) has no model-free producer`
}
sockets.push({ name, type })
}
}
const terminus =
def.output_node === true
? { needsPreviewSink: false, note: 'node is its own terminus' }
: (def.output ?? []).length > 0
? { needsPreviewSink: true, note: 'output 0 -> PreviewAny' }
: null
if (!terminus)
return {
key,
verdict: 'NO_OBSERVABLE_OUTPUT',
reason: 'no outputs and not an OUTPUT_NODE - nothing observable to queue'
}
if (sockets.length === 0)
return {
key,
verdict: 'AUTO_RUNNABLE',
needsPreviewSink: terminus.needsPreviewSink,
reason: `widgets satisfy all required inputs; ${terminus.note}`
}
return {
key,
verdict: 'CHAINABLE',
needsPreviewSink: terminus.needsPreviewSink,
requiredSockets: sockets,
reason: `${sockets.length} required socket(s) synthesized from model-free producers; ${terminus.note}`
}
}
export function planAutoRuns(
defs: Record<string, RawNodeDef & { output_node?: boolean }>,
packNodeKeys: string[]
): AutoRunVerdict[] {
// A producer only counts if the backend actually registers it.
const synthTypes = new Set(
Object.entries(SYNTH_PRODUCERS)
.filter(([, producer]) => producer.nodeType in defs)
.map(([type]) => type)
)
return packNodeKeys.map((key) =>
classifyAutoRunnable(key, defs[key], synthTypes)
)
}
// Independent chains per prompt so one bad node fails a batch, not the tier.
export function batchAutoRunnable(
verdicts: AutoRunVerdict[],
batchSize: number
): AutoRunVerdict[][] {
const runnable = verdicts.filter(
(verdict) =>
verdict.verdict === 'AUTO_RUNNABLE' || verdict.verdict === 'CHAINABLE'
)
const batches: AutoRunVerdict[][] = []
for (let offset = 0; offset < runnable.length; offset += batchSize)
batches.push(runnable.slice(offset, offset + batchSize))
return batches
}

View File

@@ -0,0 +1,82 @@
// Pack-attributed console noise with no visible error surface. Shared by
// the all-nodes tiers and the curated run tier so one ledger covers every
// surface a pack's script can emit on. Filter-guarded: a pattern suppresses
// matching errors for its pack only; stale entries are caught by review,
// not observation (several patterns are environment-conditional, so
// observed-firing guards would false-fail - see ARCHITECTURE.md section 10).
export const CONSOLE_ERROR_ALLOWLIST: Record<
string,
Array<{ pattern: RegExp; reason: string }>
> = {
'ComfyUI-Impact-Pack': [
{
// Media/text widgets preview their value via root-relative URLs at
// creation; 404s on a backend whose root does not serve the file.
pattern:
/Failed to load resource.*404.*(example\.png|plain_video\.mp4|file\.txt)/,
reason: 'media widget previews its value via a root-relative URL'
},
{
// PreviewBridge widgets fetch their internal preview id on configure;
// a bare backend has no image behind it.
pattern: /Failed to load resource.*400.*api\/impact\/get\/pb_id_image/,
reason: 'PreviewBridge fetches its preview id on configure'
},
{
// The save/reload tier writes `<value>_cn` probe values; media widgets
// preview them as URLs and 404.
pattern: /Failed to load resource.*404.*_cn/,
reason: 'set-and-stick probe value previewed by a media widget'
}
],
'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'
}
],
'ComfyUI-Custom-Scripts': [
{
// betterCombos.js:473 checks `typeof ret === "object" && "content" in
// ret`; typeof null is "object", so a null ret during save/reload
// throws `Cannot use 'in' operator to search for 'content' in null`
// as an uncaught page error - invisible until pageerror collection
// landed. Pack-owned and deterministic; upstream-report candidate.
pattern: /Cannot use 'in' operator to search for 'content' in null/,
reason: 'betterCombos.js missing null check throws during save/reload'
}
]
}
export function unallowlistedErrors(pack: string, errors: string[]): string[] {
const allowlist = CONSOLE_ERROR_ALLOWLIST[pack] ?? []
return errors.filter(
(error) => !allowlist.some((rule) => rule.pattern.test(error))
)
}
// Execution errors surface on the tiers that actually queue prompts (the
// curated run and the auto-run tier). The mount, persistence, and wiring
// tiers queue nothing, so a prompt-execution error arriving in their console
// collector is an async stray from a prior tier's still-draining execution -
// the same "not this test" principle the event-attribution filter uses
// (ARCHITECTURE section 9). It is filtered from the non-executing tiers only;
// the executing tiers still assert on it. This is not error suppression: the
// visible error SURFACES (overlay/dialog/toast) are still asserted separately
// by expectNoVisibleErrors.
const FOREIGN_EXECUTION_NOISE: RegExp[] = [
/PromptExecutionError/,
/Prompt execution failed/,
// The browser logs a rejected prompt submission as a failed resource load
// on /api/prompt. Only the executing tiers POST there, so this line in a
// mount/persistence/wiring collector is a prior tier's async submission.
/Failed to load resource.*\/api\/prompt/
]
export function isForeignExecutionNoise(error: string): boolean {
return FOREIGN_EXECUTION_NOISE.some((pattern) => pattern.test(error))
}

View File

@@ -0,0 +1,137 @@
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 tier; 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
// Node key -> evidenced reason it cannot mount under Vue Nodes 2.0; only
// the Vue mount assertion is withheld. Stale keys fail the suite.
vueIncompatibleNodes?: Record<string, string>
// Nodes that cannot execute on pure defaults. Asserted both ways: an
// unlisted failure is a regression, a listed clean run is a stale entry.
cannotRunAlone?: string[]
}
// Exported for the pure spec's validation cases; production callers go
// through loadManifest.
export function assertEntry(
entry: CustomNodeManifestEntry,
index: number
): void {
const missing: string[] = []
// CI installs the pack into custom_nodes/<pack>, and node attribution keys
// on that directory name via python_module - so pack must be a safe,
// plain path segment, not just non-empty.
if (
typeof entry.pack !== 'string' ||
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.pack)
)
missing.push('pack (must be a plain path segment)')
// CI clones from repo, so an empty value must fail here, not mid-clone.
if (typeof entry.repo !== 'string' || entry.repo.length === 0)
missing.push('repo')
// The gate tests exactly what was verified, so pin is a required full
// commit SHA. CUSTOM_NODES_ALLOW_UNPINNED=1 is the one escape hatch,
// reserved for the planned pack-HEAD canary - never for the PR gate.
if (
!/^[0-9a-f]{40}$/.test(entry.pin ?? '') &&
!(
process.env.CUSTOM_NODES_ALLOW_UNPINNED === '1' &&
(entry.pin ?? '') === ''
)
)
missing.push('pin (full 40-char commit SHA required)')
// workflow may be an empty string until the pack gains a run-tier fixture.
if (typeof entry.workflow !== 'string') missing.push('workflow')
// A run-tier row with no workflow would otherwise skip locally, leaving
// only CI's skip gate to notice the lost coverage. Fail at load instead.
else if (
entry.workflow === '' &&
Array.isArray(entry.tiers) &&
entry.tiers.includes('run')
)
missing.push('workflow (required when tiers includes "run")')
if (!Array.isArray(entry.expectedNodes) || entry.expectedNodes.length === 0)
missing.push('expectedNodes')
if (!Array.isArray(entry.tiers) || entry.tiers.length === 0)
missing.push('tiers')
// A typo like "connectivty" would otherwise pass and silently drop that
// tier's coverage - the exact drift this manifest exists to catch.
else if (entry.tiers.some((tier) => !VALID_TIERS.includes(tier)))
missing.push(`tiers (unknown value; allowed: ${VALID_TIERS.join(', ')})`)
if (!Array.isArray(entry.requiresModels)) missing.push('requiresModels')
if (typeof entry.requiresGpu !== 'boolean') missing.push('requiresGpu')
if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0)
missing.push('timeoutMs')
if (
entry.vueNodesCompatible !== undefined &&
typeof entry.vueNodesCompatible !== 'boolean'
)
missing.push('vueNodesCompatible')
if (
entry.vueIncompatibleNodes !== undefined &&
(typeof entry.vueIncompatibleNodes !== 'object' ||
entry.vueIncompatibleNodes === null ||
Array.isArray(entry.vueIncompatibleNodes) ||
Object.values(entry.vueIncompatibleNodes).some(
(reason) => typeof reason !== 'string' || reason.length === 0
))
)
missing.push('vueIncompatibleNodes (node key -> non-empty reason string)')
if (
entry.cannotRunAlone !== undefined &&
(!Array.isArray(entry.cannotRunAlone) ||
entry.cannotRunAlone.some(
(key) => typeof key !== 'string' || key.length === 0
) ||
new Set(entry.cannotRunAlone).size !== entry.cannotRunAlone.length)
)
missing.push('cannotRunAlone (unique non-empty node keys)')
if (missing.length > 0)
throw new Error(
`custom-node manifest entry ${index} (${entry.pack ?? '?'}) missing: ${missing.join(', ')}`
)
}
// Renderer passes for the load tier: LiteGraph canvas always, Vue Nodes 2.0
// unless the pack declares itself incompatible. Conditional coverage, never a
// test.skip - the caller still runs and gates on the returned passes.
export function rendererPassesFor(
entry: Pick<CustomNodeManifestEntry, 'vueNodesCompatible'>
): boolean[] {
return entry.vueNodesCompatible === false ? [false] : [false, true]
}
export function loadManifest(): CustomNodeManifestEntry[] {
const entries = JSON.parse(
readFileSync(MANIFEST_PATH, 'utf-8')
) as CustomNodeManifestEntry[]
entries.forEach(assertEntry)
return entries
}

View File

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

View File

@@ -0,0 +1,107 @@
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: 'executed'; node: string | null; output?: unknown }
| { type: 'execution_success' }
| { type: 'execution_error'; error: ExecutionError }
| { type: 'execution_interrupted'; error?: ExecutionError }
export interface RunResult {
outcome: CustomNodeOutcome
executedNodes: string[]
// ui payloads from `executed` events, keyed by node id - proof that data
// reached each output node, not just that execution finished.
outputsByNode: Record<string, unknown>
error?: ExecutionError
// Set when queuePrompt THREW client-side (pack JS hooking the queue can
// crash on a graph shape it does not expect); carries the exception text
// so the failing node self-identifies in the report.
clientError?: string
}
// `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]
}
function outputsFrom(events: PromptEvent[]): Record<string, unknown> {
const outputs: Record<string, unknown> = {}
for (const event of events) {
if (event.type === 'executed' && event.node !== null)
outputs[event.node] = event.output
}
return outputs
}
export function classifyRun(input: {
events: PromptEvent[]
expectedNodeIds: string[]
// All node ids in the queued graph. An error naming a node outside it is a
// stray from another prompt (late websocket delivery, or a duplicate queue
// from the client-flap retry) and must not be pinned on this run.
graphNodeIds?: string[]
timedOut?: boolean
}): RunResult {
const { events, expectedNodeIds, graphNodeIds, timedOut = false } = input
const executedNodes = executedNodesFrom(events)
const outputsByNode = outputsFrom(events)
if (timedOut) return { outcome: 'TIMEOUT', executedNodes, outputsByNode }
const failure = events.find(
(
event
): event is Extract<
PromptEvent,
{ type: 'execution_error' | 'execution_interrupted' }
> =>
(event.type === 'execution_error' ||
event.type === 'execution_interrupted') &&
(graphNodeIds === undefined ||
event.error?.nodeId === undefined ||
graphNodeIds.includes(event.error.nodeId))
)
if (failure)
return {
outcome: 'EXECUTION_ERROR',
executedNodes,
outputsByNode,
error: failure.error
}
if (!events.some((event) => event.type === 'execution_success'))
return { outcome: 'TIMEOUT', executedNodes, outputsByNode }
const ranEveryExpected = expectedNodeIds.every((node) =>
executedNodes.includes(node)
)
return {
outcome: ranEveryExpected ? 'PASS' : 'PARTIAL',
executedNodes,
outputsByNode
}
}

View File

@@ -0,0 +1,293 @@
// 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
// COMBO slots: the literal option list, for same-vocabulary pairing.
comboOptions?: unknown[]
}
export interface NormalizedNode {
type: string
pack: string
inputs: NormalizedSlot[]
outputs: NormalizedSlot[]
// Slots whose raw spec carried no recognizable type (slotTypeOf null):
// recorded so a schema change can never silently shrink the corpus.
unknownSlots?: string[]
}
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 slots with no same-vocabulary partner in the corpus, excluded:
// isValidConnection only compares the string COMBO while each slot carries
// its own option set, so pairing across different vocabularies proves
// nothing (a checkpoint dropdown would "connect" to a scheduler dropdown).
// Combos whose option lists match exactly ARE paired like any other type.
combos: Array<SlotRef & { dir: 'in' | 'out' }>
// Slots dropped at normalize time because their raw spec had no
// recognizable type - surfaced here (and logged by the sweep) so a
// backend or pack schema change cannot silently shrink the corpus.
unknownShapes: string[]
}
// 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,
unknown: string[]
): 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) {
unknown.push(name)
continue
}
const opts = specArray[1] as
| { socketless?: boolean; options?: unknown }
| undefined
// socketless = widget only, no slot: not connectable, out of the matrix.
if (opts?.socketless) continue
if (type === 'COMBO') {
// Raw defs carry the option list as the type literal; the frontend's
// transformed defs use the string 'COMBO' with options in the opts.
const options = Array.isArray(specArray[0])
? (specArray[0] as unknown[])
: Array.isArray(opts?.options)
? opts.options
: undefined
slots.push({ name, type, comboOptions: options })
continue
}
slots.push({ name, type })
}
return slots
}
export function normalizeNodeDefs(
defs: Record<string, RawNodeDef>
): NormalizedNode[] {
return Object.entries(defs).map(([type, def]) => {
const unknown: string[] = []
const node: NormalizedNode = {
type,
pack: packOf(def.python_module),
inputs: [
...inputSlots(def.input?.required, unknown),
...inputSlots(def.input?.optional, unknown)
],
outputs: (def.output ?? []).flatMap((rawType, index) => {
const slotType = slotTypeOf(rawType)
if (slotType === null) {
unknown.push(`output[${index}]`)
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]
const slot: NormalizedSlot = {
name: typeof rawName === 'string' ? rawName : slotType,
type: slotType
}
if (slotType === 'COMBO') slot.comboOptions = rawType as unknown[]
return [slot]
})
}
if (unknown.length > 0) node.unknownSlots = unknown
return node
})
}
// 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))
)
// COMBO slots pair only on an identical option vocabulary; the string type
// alone would let a checkpoint dropdown "connect" to a scheduler dropdown.
// Vocabulary equality is a SET comparison: a wired input bypasses its own
// widget, so menu order and the options[0] default do not participate in
// the wire contract - only membership does (backend validation checks
// "value in options"). Values still compare as exact strings.
const vocabOf = (slot: NormalizedSlot) =>
JSON.stringify(
(slot.comboOptions ?? []).map((option) => JSON.stringify(option)).sort()
)
// A combo whose option list is unknown (transformed defs without an
// options array) must never pair - a blind match would wire dropdowns
// with no vocabulary evidence at all.
const comboProducers = sorted.flatMap((node) =>
node.outputs
.filter(
(slot) => slot.type === 'COMBO' && Array.isArray(slot.comboOptions)
)
.map((slot) => ({ ref: slotRef(node, slot), vocab: vocabOf(slot) }))
)
const comboConsumers = sorted.flatMap((node) =>
node.inputs
.filter(
(slot) => slot.type === 'COMBO' && Array.isArray(slot.comboOptions)
)
.map((slot) => ({ ref: slotRef(node, slot), vocab: vocabOf(slot) }))
)
const plan: PairingPlan = {
pairs: [],
orphans: [],
wildcards: [],
combos: [],
unknownShapes: all.flatMap((node) =>
(node.unknownSlots ?? []).map((slot) => `${node.type}.${slot}`)
)
}
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') {
const producer = Array.isArray(slot.comboOptions)
? comboProducers.find(
(candidate) => candidate.vocab === vocabOf(slot)
)
: undefined
if (producer) addPair(producer.ref, slotRef(node, slot))
else 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') {
const consumer = Array.isArray(slot.comboOptions)
? comboConsumers.find(
(candidate) => candidate.vocab === vocabOf(slot)
)
: undefined
if (consumer) addPair(slotRef(node, slot), consumer.ref)
else plan.combos.push({ ...slotRef(node, slot), dir: 'out' })
continue
}
const consumer = consumers.find((candidate) =>
isTypeCompatible(slot.type, candidate.slotType)
)
if (consumer) addPair(slotRef(node, slot), consumer)
else plan.orphans.push({ ...slotRef(node, slot), dir: 'out' })
}
}
return plan
}

View File

@@ -0,0 +1,151 @@
[
{
"pack": "ComfyUI-Impact-Pack",
"repo": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
"pin": "429d0159ad429e64d2b3916e6e7be9c22d025c3c",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/impact_primitives_run.json",
"expectedNodes": ["ImpactInt", "ImpactFloat"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"AnyPipeToBasic",
"CLIPSegDetectorProvider",
"ImpactMakeImageBatch",
"ImpactMakeMaskBatch",
"LatentSender",
"MasksToMaskList",
"PreviewBridgeLatent"
]
},
{
"pack": "ComfyUI-VideoHelperSuite",
"repo": "https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite",
"pin": "4ee72c065db22c9d96c2427954dc69e7b908444b",
"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"
]
},
{
"pack": "rgthree-comfy",
"repo": "https://github.com/rgthree/rgthree-comfy",
"pin": "27b4f4cdcf3b127c29d5d8135ac1536ecbd4c383",
"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": "9d9f4bedfc9f0321c19faf71855e228c93bd0dc9",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/essentials_math_display_run.json",
"expectedNodes": ["SimpleMathInt+", "DisplayAny"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"ImageApplyLUT+",
"ImageUntile+",
"MaskFromList+",
"PixelOEPixelize+",
"SimpleCondition+",
"SimpleMath+",
"SimpleMathCondition+",
"SimpleMathDual+"
]
},
{
"pack": "ComfyUI-KJNodes",
"repo": "https://github.com/kijai/ComfyUI-KJNodes",
"pin": "e27a505b3ba6ce42687fe00500deda103d9d6071",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/kjnodes_constants_run.json",
"expectedNodes": ["INTConstant", "FloatConstant"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": [
"CameraPoseVisualizer",
"CreateAudioMask",
"CreateGradientFromCoords",
"CreateInstanceDiffusionTracking",
"CreateShapeImageOnPath",
"CreateShapeMaskOnPath",
"CreateTextOnPath",
"CrossFadeImages",
"CrossFadeImagesMulti",
"CustomControlNetWeightsFluxFromList",
"CutAndDragOnPath",
"EndRecordCUDAMemoryHistory",
"FloatToMask",
"GetImagesFromBatchIndexed",
"GetLatentsFromBatchIndexed",
"ImageAndMaskPreview",
"ImageGridtoBatch",
"ImagePadForOutpaintTargetSize",
"InterpolateCoords",
"LoadImagesFromFolderKJ",
"LoadVideosFromFolder",
"PlotCoordinates",
"StartRecordCUDAMemoryHistory",
"Superprompt",
"VisualizeCUDAMemoryHistory",
"WebcamCaptureCV2",
"WeightScheduleConvert",
"WeightScheduleExtend",
"WidgetToString"
]
},
{
"pack": "ComfyUI-Custom-Scripts",
"repo": "https://github.com/pythongosssss/ComfyUI-Custom-Scripts",
"pin": "609f3afaa74b2f88ef9ce8d939626065e3247469",
"tiers": ["load", "connectivity", "run"],
"workflow": "assets/customNodes/customscripts_string_show_run.json",
"expectedNodes": ["StringFunction|pysssss", "ShowText|pysssss"],
"requiresGpu": false,
"requiresModels": [],
"timeoutMs": 30000,
"cannotRunAlone": ["MathExpression|pysssss"]
},
{
"pack": "was-node-suite-comfyui",
"repo": "https://github.com/WASasquatch/was-node-suite-comfyui",
"pin": "ea935d1044ae5a26efa54ebeb18fe9020af49a45",
"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",
"Image Send HTTP",
"Latent Batch",
"Mask Batch",
"Samples Passthrough (Stat System)",
"Text Dictionary Convert",
"Text to Number"
]
}
]

View File

@@ -6,6 +6,10 @@ 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
@@ -41,10 +45,6 @@ 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,29 +64,30 @@ export class TemplateHelper {
}
async mock(): Promise<void> {
await this.mockCustomTemplates()
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
async mockCustomTemplates(): Promise<void> {
const customTemplatesHandler = async (route: Route) => {
const customTemplates: Record<string, string[]> = {}
await route.fulfill({
status: 200,
body: JSON.stringify(customTemplates),
body: '{}',
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({
@@ -98,9 +99,8 @@ export class TemplateHelper {
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
}
async mockThumbnails(): Promise<void> {
@@ -114,12 +114,8 @@ export class TemplateHelper {
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
@@ -129,15 +125,6 @@ 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(

View File

@@ -7,10 +7,6 @@ export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
await use(createTemplateHelper(page))
}
})

View File

@@ -0,0 +1,28 @@
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
const url = message.location().url
errors.push(url ? `${message.text()} [${url}]` : message.text())
}
// Uncaught page exceptions and unhandled promise rejections never reach
// console.error; Chromium surfaces both through pageerror. Without this
// listener a pack script crashing outside a console call passes silently.
const pageErrorListener = (error: Error) => {
errors.push(`Uncaught page error: ${error.message}`)
}
page.on('console', listener)
page.on('pageerror', pageErrorListener)
return {
errors,
stop: () => {
page.off('console', listener)
page.off('pageerror', pageErrorListener)
}
}
}

View File

@@ -0,0 +1,71 @@
import type { Page } from '@playwright/test'
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' })
}
// Every test gets a fresh page, but they share ONE backend. An execution
// tier that ends while a prompt is still draining leaves that work running
// on the shared backend; the next test's fresh page connects mid-execution
// and catches its async error events (console noise, a popped error dialog)
// or its still-running prompt (queue-busy). Draining to idle in an afterEach
// - while the finishing test's own page is still open, so any late events
// land there - is what makes each test unable to affect the next. getQueue
// swallows a failed fetch and returns an empty queue, so throw-on-error and
// treat a failed read as still-busy; the wait is free when already idle
// (one getQueue round-trip), so a healthy suite pays ~nothing for it.
// Returns 0 when the backend reached idle, 1 when it was still busy after the
// budget (a genuinely wedged, non-interruptible execution). The afterEach hook
// ignores the result; the auto-run tier asserts on it.
export async function drainBackendToIdle(
page: Page,
budgetMs = 150_000
): Promise<number> {
const depth = () =>
page.evaluate(async () => {
try {
const queue = await window.app!.api.getQueue({ throwOnError: true })
return queue.Running.length + queue.Pending.length
} catch {
return Number.POSITIVE_INFINITY
}
})
if ((await depth()) === 0) return 0
await page.evaluate(async () => {
await window.app!.api.interrupt(null)
await window.app!.api.clearItems('queue')
})
const deadline = Date.now() + budgetMs
let remaining = await depth()
while (remaining !== 0 && Date.now() < deadline) {
await page.evaluate(
() => new Promise((resolve) => setTimeout(resolve, 500))
)
remaining = await depth()
}
return remaining === 0 ? 0 : 1
}

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

View File

@@ -119,6 +119,11 @@ class NodeSlotReference {
const rawPos = node.getConnectionPos(type === 'input', index)
const convertedPos =
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
// page.mouse needs page coords; pack JS can inject chrome above the
// canvas (rgthree's progress bar), shifting it off (0,0).
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(

View File

@@ -0,0 +1,397 @@
# 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),
and its system design in [ARCHITECTURE.md](ARCHITECTURE.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 and asserts under EACH renderer that the
instance materializes everything its def declares - every non-socketless
input exists as a widget or a socket (autogrow templates count via their
expansion slots) and every declared output exists; the Vue pass
additionally asserts the DOM renders at least the instance's widget and
slot counts - a mount with missing controls fails. It then round-trips
every node through save/reload (every widget
is first written with a non-default value that must stick, and the
serialized `widgets_values` must survive configure unchanged), plans typed
connections for all its concrete slots (COMBO slots pair when they offer
the same option SET - order-insensitive, since a wired input bypasses its
own widget and only membership matters), and executes it for real when it
can run:
either self-sufficient (every required input is a widget with a valid
default) or `CHAINABLE` - every required socket type has a model-free
producer (`EmptyImage`, `EmptyLatentImage`, `SolidMask`, `Primitive*`,
`EmptyAudio`, ...) that the runner synthesizes and wires automatically.
Executed nodes must observably produce: the `PreviewAny` sink wired to the
node's first output must emit a ui payload, or the node is its own
terminus (`OUTPUT_NODE`). Nodes that cannot run are classified and
logged, never silently dropped: `NEEDS_WIRES` (a required socket type has
no model-free producer - MODEL, SEGS, CONDITIONING...), `NEEDS_MODELS`
(empty model/file combo on the bare backend), `NO_OBSERVABLE_OUTPUT`
(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
```
The clone directory name must equal the manifest `pack` key: node
attribution keys on that directory via `python_module`, and CI installs
into `custom_nodes/<pack>` for the same reason.
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` | Required: the full 40-char commit SHA you verified locally. The manifest loader rejects anything else at load and CI fails before install (empty is accepted only under `CUSTOM_NODES_ALLOW_UNPINNED=1`, reserved for the planned pack-HEAD canary). CI checks it out after cloning, so the gate tests exactly what you tested. Bump deliberately, re-verifying per this doc. |
| `tiers` | Tier gates: `connectivity` (typed links + slot drags) and `run` (executes the workflow) enable their tiers; `load` is descriptive only - the register+render pass runs for every row regardless. Keep 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
zero-visible-errors invariant held for the tiers that assert it (mount,
persistence, connectivity, core smoke, curated workflows): no error
overlay, dialog, node error, or error toast. Two deliberate exceptions,
same as the README: the auto-run execution tier provokes expected
failures, and the self-check inverts the invariant. 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 typed, or combo typed with no same-vocabulary partner (wildcards
bypass the real type compare; combos pair only when their option lists
match exactly). 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 or synthesized chain inputs (validation reject,
or a real exception from degenerate inputs - empty expression, empty
coordinate JSON, single-frame batch, missing optional python dep). 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. Confidence note: a chain failure proves the
node cannot run on synthesized inputs, not that it is broken - the inputs
may be semantically insufficient (e.g. a coordinates STRING fed an empty
string).
- **Auto-run reports `NO_OUTPUT`**: the node executed but its `PreviewAny`
sink emitted no ui payload - data never actually flowed out of the node.
Treat like any other cannot-run failure: regression or baseline entry.
- **Auto-run fails with `HUNG_BACKEND`**: a node blocked forever during
execution. Observed mechanism classes so far: model downloads at execute
(BLIP/SAM/MiDaS/rembg/CLIPSeg `from_pretrained`), runtime
`pip install` inside execute (WAS lazy-install), minutes-long pure-Python
per-pixel loops, and an infinite `while` on empty-string defaults. 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). Everything queued
behind the offender reports `HUNG_BACKEND` too - identify the true
offender (backend log, `/queue`) before excluding victims.
- **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 (in
`fixtures/customNode/consoleErrorLedger.ts`, shared by the all-nodes
tiers and the curated run) 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 or unstable (runtime downloads/pip installs, infinite loops, non-interruptible hangs, environment/state-variable results, flip-flopping executed signals) |
| `WIDGET_SET_ALLOWLIST` | `allNodes.spec.ts` | plain-typed widget whose value is owned by pack JS (menu-action combos, canonicalized refs) - set-and-stick does not apply |
| `ROUNDTRIP_VALUE_ALLOWLIST` | `allNodes.spec.ts` | node whose serialized widgets_values legitimately change on reload (pack JS initializes or rebuilds them); the widget-shrink check still applies |
| `MOUNT_WIDGET_ALLOWLIST` | `allNodes.spec.ts` | node whose pack JS renders custom editor/preview widgets outside the node-widget rows; slot fidelity still applies |
| `CONSOLE_ERROR_ALLOWLIST` | `fixtures/customNode/consoleErrorLedger.ts` | pack-attributed console noise with no visible error surface; shared by the all-nodes tiers and the curated run |
| `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 |
### Evidence rules for changing the harness itself
Two bug classes shipped past green tests once, so these are now policy:
- **Ground assertions in an oracle you did not write.** A semantic claim
about how ComfyUI behaves (what a wire accepts, what an event means, when
a widget exists) must cite a live probe, the backend/frontend source, or
a CI observation - never plausibility. If every layer agreeing with you
was authored from your own mental model (code, fixtures, measurement
script), their agreement is not evidence.
- **Parse live data against a shape census, not memory.** Node defs reach
the suite through `getNodeDefs`, which emits BOTH schema forms (combo as
an option-list literal AND as the string `COMBO` with `options`/`remote`
in the opts; `forceInput` on any form; autogrow `template` inputs;
`socketless`). Any parser of def shapes must handle every form the census
shows, its pure-spec fixtures must include each form (copied from real
census examples, not invented), and an unrecognized shape must be
excluded WITH a record - never silently matched or silently skipped.
- **Verify against the source the code consumes.** Measuring raw
`/object_info` proves nothing about code that reads the transformed
`getNodeDefs` object.
## 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 after a pin bump, re-pin the
pack to its last good commit. Never paper
over it with a skip.
## Vue Nodes 2.0 compatibility policy
Some packs only work under the LiteGraph canvas renderer and fail to mount
under Vue Nodes 2.0. The suite must state that fact without producing false
failures and without skipping tests:
1. **Default**: every pack is assumed compatible. New rows omit
`vueNodesCompatible`.
2. **Evidence rule**: set `"vueNodesCompatible": false` ONLY after the T0
Vue pass fails for the pack locally while the LiteGraph pass is green,
and the failure reproduces on a retry. A README grumble, a hunch, or an
old forum thread is not evidence. Record the evidence (the failing
assertion and the pack version) in the PR description of the change that
sets the flag. When only SOME of a pack's nodes fail to mount, use the
per-node `vueIncompatibleNodes` ledger in the manifest row instead of
flagging the whole pack - compatibility is per-node, not per-pack (all
823 nodes across the first 7 packs mount clean, so both mechanisms ship
unused; the every-node mount tier is what earns an entry).
3. **Effect of `false`**: the load tier runs its LiteGraph pass only, and
the connectivity drag test does not drag that pack's edges under Vue
Nodes. The tests still run and pass their canvas assertions - nothing is
`test.skip`ped, so the CI skip gate stays honest. The run tier and the
connectivity contract sweep are renderer-independent (they never toggle
the Vue Nodes setting) and run for the pack regardless of the flag - a
flagged pack must still execute and wire cleanly there.
4. **Un-flagging**: if a pack ships Vue Nodes support later, delete the flag
and prove T0 green in both passes locally.
## Checklist
- [ ] Pack installs clean on the test backend (no `IMPORT FAILED`)
- [ ] Checked whether the pack ships frontend JS (Step 1 `/extensions` probe)
- [ ] `expectedNodes` copied exactly from `/object_info` (Step 2 traps checked)
- [ ] All expected nodes are model-free and present in the run workflow
- [ ] Workflow JSON under `browser_tests/assets/customNodes/`, no new binaries
- [ ] Any media staged into the backend's own `input/` dir locally (Step 4)
- [ ] Manifest row appended with every field (Step 5 table)
- [ ] `vueNodesCompatible` omitted, or set `false` with recorded evidence
- [ ] 6a green: `pnpm test:custom-nodes` against the dev server, zero skips
- [ ] 6b green when the pack ships frontend JS: built dist + backend-served run
- [ ] Every-node tiers green: no unexplained mount/save-reload/auto-run
failures; any new ledger entry carries its mechanism
- [ ] Pushed; `CI: Tests Custom Nodes` green on the PR

View File

@@ -0,0 +1,715 @@
# Custom-node regression suite architecture
The design of the custom-node regression suite: what it is made of, how the
pieces cooperate, the decisions behind them, and the gotchas that shaped
them. Companion docs: [README.md](README.md) (how to run it),
[ADDING_CUSTOM_NODES.md](ADDING_CUSTOM_NODES.md) (how to onboard a pack).
The document is organized as eight architecture views; the diagram map
under "Reading paths" shows what question each answers and how they nest.
Implementation symbols live in one place: the implementation map at the
end (section 14).
## What / Why / How, in one minute
**What it proves.** On every PR, for every node that the manifest's
community packs register on a real backend, the suite proves four concrete
things: the node mounts completely in both renderers (the canvas renderer,
LiteGraph, and the DOM renderer, Vue Nodes 2.0), it survives save/reload,
its slots wire type-correctly, and it executes when its inputs allow.
Section 1 states each proof precisely.
> **Scale snapshot (example, at the time of writing):** 7 packs, 823
> registered nodes, about 5,000 planned wiring checks, about 440 nodes
> executing clean per run. These are observations printed by the run, not
> properties of the design; they move whenever the manifest or a pin moves.
**What it does NOT prove.** Output semantics, frontend-only nodes, and
hour-scale soak behavior are out of scope; section 1 states the non-goals
precisely. Green means "every registered node still mounts, saves, wires,
and runs," and nothing wider: a compatibility and regression gate, not a
behavior certifier.
**Why it exists.** Regressions against real community packs used to be
invisible: the frontend could break widely installed packs and no test
would fail, because nothing exercised those packs at all. Claims about
which packs worked were anecdotes with no receipts. The suite turns "most
packs are broken" or "this one is fine" from an opinion into a per-node,
reproducible result attached to a PR.
**How it works, in one paragraph.** One manifest row per pack (source,
pinned version, tiers, a tiny curated workflow) drives everything; there is
no per-pack test code. The suite reads each pack's real node list live from
the backend, derives what every node should be able to do, and verifies it
in a real browser against a real backend with the pack's own frontend
scripts active. Every exception is a reviewed record that carries its
causal mechanism, every exception list is guarded against going stale
(section 10 grades the strength of each guard), and execution results are
reconciled in both directions against a known-failure baseline, so the
gate can neither hide a regression nor accumulate dead exemptions.
Nothing is ever skipped; a skip fails the job.
## Reading paths
- **Skeptical about what green actually covers?** Section 1 (what it proves
and the non-goals) and section 12 (the gotchas: every real incident, its
root cause, and the defense).
- **Deciding pack strategy** (which packs to keep, which renderers to
support): section 11 (design decisions and their trade-offs) and the Vue
Nodes compatibility policy in ADDING_CUSTOM_NODES.md. A pack is one
manifest row to add or remove.
- **Onboarding a pack:** ADDING_CUSTOM_NODES.md, not this doc. This doc is
the why; that doc is the step-by-step.
- **Debugging a red run:** the failure-class list in ADDING_CUSTOM_NODES.md
maps each red message to a cause; sections 7 and 10 show where in the pipeline it
happened; section 12 gives symptom-first triage.
How to read the diagrams: a rectangle is one step, named by its purpose; a
diamond is a short question, drawn only where the flow genuinely forks; a
check that cannot fork is a "Check:" step, not a diamond; a titled group
is a thing with internal structure; mechanism detail lives in the prose
under each diagram, not stacked inside boxes.
The eight views are zoom levels of one mental model, not eight parallel
pictures. Every arrow below names the element of the parent view that the
child expands. The map is ordered by zoom, not by page order: arrows say
what contains what, section numbers say where to read.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 240}}}%%
flowchart LR
L1["System context (section 2): who and what the suite touches"]
L2["Building blocks (section 4): what the suite is made of"]
L3["Definition pipeline (section 6): where every check's expectations come from"]
L4["Execution flow (section 7): how a foreign node gets run safely"]
L5["Persistence check (section 8): how save and reload are proven"]
L6["Event attribution (section 9): when an arriving event may be believed"]
L7["Evidence model (section 10): how exceptions stay honest"]
L8["CI deployment view (section 13): the order the test world is built in"]
L1 -->|"opens the suite boxes"| L2
L1 -->|"expands the CI arrow"| L8
L2 -->|"the definition parsers"| L3
L2 -->|"the Execution tier"| L4
L2 -->|"the Persistence tier"| L5
L2 -->|"the Evidence Ledgers box"| L7
L4 -->|"the collect-events step"| L6
```
The mount and wiring tiers have no diagram on purpose: each is a
single-shot comparison with nothing to sequence, so they live as prose and
tables in section 5.
## 1. What this suite proves, and deliberately does not
For every node that the manifest's packs register on the backend,
re-discovered live on every run:
- the node **mounts completely** in both renderers: the instance
materializes every input and output its definition declares, and under
the DOM renderer the page renders at least the instance's widget and
slot counts
- the node **survives save/reload**: no widget silently disappears and no
serialized value silently changes across a save/reload round-trip, and a
user-like non-default write sticks and survives a second reload, under
both renderers (dynamic widgets the application itself adds on reload are
expected and allowed, see section 8)
- the node's concrete slots **wire type-correctly** through the real
connection validator, and the wires survive save, reload, and prompt
serialization
- the node **executes on a real backend** when its inputs allow it, and its
output observably arrives at an observation sink
Every tier also asserts the app shows **zero visible errors** while doing
this, except the execution tier, which deliberately provokes expected
failures (section 7).
Deliberately out of scope: output semantics (does a blur actually blur),
frontend-virtual nodes that never register on the backend, and hour-scale
soak behavior. A rare intermittent glitch that only surfaces after long
interactive use (a widget that occasionally shrinks on its own) is soak
behavior: this per-PR gate will not catch it, and does not claim to.
## 2. System context
Who and what the suite touches.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 220}}}%%
flowchart LR
CIP["CI platform: runs the gate on every PR"]
PACKS["Community node packs: external code, installed at pinned versions"]
DRIVER["Suite test driver: puts every pack node through its create, wire, save, and submit checks"]
FE["ComfyUI frontend: the system under test, running in a real browser"]
BE["ComfyUI backend: real graph execution engine"]
SYN["Suite verdict synthesis: turns observations into per-node verdicts + exceptions"]
TEAM["Engineering team: consumes verdicts and the evidence ledgers"]
CIP -->|"builds the environment, triggers"| DRIVER
DRIVER -->|"drives a real browser session"| FE
FE <-->|"definitions, prompts, execution events"| BE
FE -->|"observations: mounts, persistence, execution, errors"| SYN
SYN --> TEAM
PACKS -->|"frontend scripts load into"| FE
PACKS -->|"python side installs into"| BE
```
The two "Suite" boxes are the same system, split so the flow reads one way:
the driver puts the frontend through its paces, and verdict synthesis turns
what came back into the per-node verdicts and mechanism-carrying exceptions
the team consumes. Nothing flows backwards.
The load-bearing property: the suite tests the same stack a user runs. The
pack's own frontend scripts are active, the backend actually executes
graphs, and nothing is mocked.
## 3. The verification environment
The environment must have these properties, or the suite reports green
while testing the wrong thing:
| Requirement | Why |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The backend serves the **built** frontend, and tests point at the backend | The dev server loads core extension scripts only, so pack frontend scripts never run under it. Packs that restyle nodes, rebuild widgets, or hook the submission path behave completely differently. Both early "green locally, red on CI" incidents were this. |
| Execution caching disabled | Per-node "it actually ran" signals are only emitted for non-cached executions; with caching on, a node can pass without running. |
| Isolated test users | Test state must not leak between runs or into a developer's real workspace. |
| One test worker | The backend's execution queue is a shared, exclusive resource. Two workers interrupt each other's work and misattribute events. |
## 4. Building blocks
What the suite is made of. The main flow is a straight pipeline; the shared
services that support the tiers are listed in the table below it.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 240}}}%%
flowchart LR
MAN["Pack Manifest: source, pin, tiers, known-failure baseline per pack"]
ORCH["Test Orchestrator: runs every row through the tiers, honoring the row's tier gates (section 5)"]
subgraph TIERS ["Verification tiers (section 5)"]
TM["Mount Completeness"]
TP["Persistence"]
TW["Wiring Compatibility"]
TX["Execution"]
TM ~~~ TW
TP ~~~ TX
end
EVID["Evidence Ledgers + Reconciler: every result collected, every exception carries its mechanism, lists cannot go stale"]
GATE["Gate verdict + evidence for the team"]
MAN -->|"drives"| ORCH
ORCH -->|"runs, per pack"| TIERS
TIERS -->|"all results and exceptions"| EVID
EVID -->|"green only if everything is accounted for"| GATE
```
The shared services behind the tiers:
| Service | Used by | Responsibility |
| --------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Definition Normalizer | Wiring (slot model); every all-nodes tier (pack attribution, node keys) | one canonical connectable-slot model out of the multiple definition dialects (section 6), feeding the pairing planner |
| Capability Classifier | Execution | decides, per node, what it can do without hand-written fixtures: run on its own defaults, run with synthesized inputs, or blocked, with the reason recorded (section 7) |
| Execution Harness | Execution | runs nodes for real and attributes every outcome to the right node despite an asynchronous, noisy event stream (sections 7 and 9) |
Two further tiers (curated workflows, core smoke) sit alongside these four
but are fixture-driven rather than derived from the node corpus; section 5
lists all six.
Dialect handling is deliberately not centralized. Mount and the Capability
Classifier read the raw definitions through their own purpose-built
parsers (`declaredShape`, `classifyInput`), because each needs a different
slice of a definition (declared parts vs. runnability); the normalizer's
slot model feeds the wiring planner alone, though the all-nodes tiers
also call it for pack attribution and node-key derivation. What keeps the
three parsers from drifting is shared evidence, not shared code: each is
pinned by fixtures copied from a live census of both definition dialects
(section 6).
- **Pack Manifest**: the single extension point. Adding a pack is one row;
no tier knows pack names.
- **Evidence Ledgers**: the honesty mechanism. An exception without a
recorded mechanism is not allowed to exist (section 10).
## 5. The verification tiers
| Tier | Verifies | Renderers | Notes |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Mount Completeness | every declared input and output actually materializes on the created node; the DOM renderer additionally shows at least the instance's widget/slot counts | both; a pack declared Vue-incompatible runs canvas only | missing parts fail; extras are tolerated |
| Persistence | save/reload loses nothing and changes nothing; user-like writes stick and survive reload | both; a pack declared Vue-incompatible runs canvas only | application-added dynamic widgets are legal; see section 8 |
| Wiring Compatibility | one representative typed wire per slot connects through the real validator and survives save, reload, and prompt serialization | breadth sweep: one, by decision 7; curated drags: both | dropdown slots pair only on identical option sets; see section 10 for exception routing |
| Execution | the node runs on a real backend and its output arrives at an observation sink | one, by decision 7 | the full flow is section 7 |
| Curated workflows | a small hand-authored graph per pack executes end to end; its named must-exist nodes are asserted present (a missing one fails the tier, catching a pack that renamed or dropped a node) | both (render pass) | plus a forced-error self-check proving the harness detects real failures |
| Core smoke | the core app loads a workflow cleanly with packs installed | both | guards against packs breaking the base app |
One vocabulary bridge, because the manifest predates these tier names: the
manifest row's `tiers` field takes `load`, `run`, `connectivity`, and
`io`. Today `run` gates the curated workflow execution, `connectivity`
gates the wiring tier, and everything else ignores the field: mount,
persistence, execution, and the curated render pass run for every row
unconditionally, and core smoke is pack-independent. `load` and `io` are
accepted by the schema but currently gate nothing.
## 6. The node-definition pipeline
Where the suite's knowledge of every node comes from: definitions flow left
to right, and three independent parsers derive three plans from one live
census.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 380}}}%%
flowchart LR
PUB["Backend publishes node definitions"] --> CORPUS["Live definition census: every node the packs register, re-discovered each run, in two dialects"]
CORPUS -->|"wiring slot normalizer"| W["Wiring plan: which slots can pair, and why"]
CORPUS -->|"execution classifier"| X["Execution plan: which nodes can run, and why the rest cannot"]
CORPUS -->|"mount declared-shape parser"| M["Mount expectations: what each created node must materialize"]
```
The three plans are independent consumers of the same census, each through
its own dialect-aware parser (section 4 names the symbols): the wiring
plan feeds the Wiring Compatibility tier, the execution plan feeds the
Execution tier, and the mount expectations feed Mount Completeness.
Design rule that came from a real bug: every consumer must handle **both
definition dialects** (legacy list-form and V2 object-form), and anything
with an unknown shape is excluded with a record, never silently matched or
skipped. The dialects differ in where dropdown options live, how "must be
wired" is flagged, and how growable input groups are declared; details and
evidence rules are in ADDING_CUSTOM_NODES.md.
## 7. The execution flow
How the suite runs hundreds of foreign nodes safely, with no fixtures, and
still attributes every failure to the right node.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 700}}}%%
flowchart TD
CLASS["Classify each node: what can it do with no hand-written fixtures?"]
CLASS --> RUND["runnable on its own defaults"]
CLASS --> RUNS["runnable with synthesized inputs"]
CLASS --> BLOCK["blocked: the reason is recorded"]
RUND --> BATCH["Group runnable nodes into small batches: a failure stays isolated, and one submission carries many nodes instead of paying the round-trip per node"]
RUNS --> BATCH
BLOCK --> REC
BLOCK ~~~ BATCH
BATCH --> TG
subgraph TG ["Build the batch's disposable test graph: one isolated chain per node"]
PROD["synthetic producers for each required input"] --> NUT["the node under test"]
NUT --> SINK["an observation sink on its output"]
end
TG --> SUBQ["Submit the assembled batch graph for real execution"]
SUBQ --> GUARD{"submission outcome?"}
GUARD -->|"crashed inside a pack's own script"| ERR
GUARD -->|"accepted"| OBSERVE["Collect the execution events as the graph runs, keeping only events that belong to this submission and name a node in this test graph (section 9)"]
OBSERVE --> V{"outcome?"}
V -->|"ran, output observed at the sink"| CLEAN["clean"]
V -->|"ran, nothing arrived at the sink"| NOOUT["failure: data never flowed"]
V -->|"error attributed to this graph"| ERR["failure: named node, named cause"]
V -->|"no response in time"| TRIP["tripwire: interrupt the engine, then watch whether the queue drains"]
TRIP --> INT{"recovers?"}
INT -->|"yes"| ERR
INT -->|"no"| HUNG["engine wedged: stop the tier and name the batch as suspects; queued nodes are victims, not findings"]
ERR --> BIS["re-run each batch member alone, so the offender names itself"]
NOOUT --> BIS
CLEAN --> REC
BIS --> REC["Reconcile with the known-failure baseline, in BOTH directions: an unlisted failure fails the gate; a listed entry that now passes, or can no longer run at all, also fails it. Exclusion ledgers are stale-guarded separately"]
```
Synthesized inputs are produced by a small set of self-sufficient producer
nodes (an empty image, an empty latent, a solid mask, primitive values), so
"runnable with synthesized inputs" needs no per-node authoring. The
observation sink is what upgrades "it finished" to "its output actually
arrived somewhere."
The submission guard is why a crash inside a pack's own script can never
abort the tier: the throw is caught in the page, recorded as that node's
failure with the client error text, and the run moves on.
## 8. The persistence check
Why it is staged: the DOM renderer's widget components react to creation
and reload on their own schedule, and a check that snapshots synchronously
would compare state those reactions never touched. The whole pass runs once
per renderer.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 240}}}%%
flowchart LR
P1["Stand up: create every node of the pack, let the UI settle"]
P2["Round-trip: snapshot, reload from the snapshot, snapshot again"]
P3["Check: nothing lost, nothing changed; additions the application itself makes are legal"]
P4["Probe: write a user-like non-default value into every plain widget, verify every write sticks"]
P5["Round-trip again: snapshot, reload from the snapshot, snapshot again"]
P6["Check: written values survive wherever the node's shape stayed stable (a changed dropdown can legally rebuild a dynamic node's widgets)"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6
```
Between phases the rig yields to the UI so renderer effects flush before
the next snapshot; those settle points are what makes the staging real.
Widgets whose values the pack's own script owns (canonicalized references,
embedded editors) are exempt from probe writes, each with a recorded
mechanism: writing probe markers into them only makes the pack's script
choke on the probe.
## 9. Event attribution
Real execution reports back over an asynchronous event stream, and the
stream can mislead in two specific ways. Both produced real misattributed
failures before the filters existed. The primary defense is positive: when
the harness submits a graph, it captures the id the backend assigns to
that submission from the submission response itself, so an event's
ownership is checked against a known id, never inferred from history.
Every arriving event passes the same two questions before it may count as
evidence:
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 280}}}%%
flowchart TD
EV["an event arrives on the execution stream, while this attempt runs"]
EV --> Q1{"from THIS attempt?"}
Q1 -->|"no: it does not carry the id this submission was assigned"| DROP["dropped: a stray cannot blame any node in this run"]
Q1 -->|"yes"| Q2{"names a node in THIS test graph?"}
Q2 -->|"no: it names another graph's nodes"| DROP
Q2 -->|"yes"| KEEP["kept: evidence for exactly that node"]
```
Both no-answers are checkable, not hopeful. The first is a comparison
against the captured submission id: an event either carries it or it does
not. If that capture ever misses, the harness says so on the console and
falls back to identity bookkeeping, recording every attempt identity it
has ever seen so a late event from an observed attempt still identifies
itself. The second question defeats the one stray the first cannot: a
retried duplicate arriving under a never-seen identity. Node identities
are never reused within a session, so such an event can only name an
earlier graph's nodes. Membership is decisive.
## 10. The evidence model
The suite's honesty mechanism. Every exception is a reviewed record that
names its causal mechanism, and every list is guarded: an entry naming a
node the pack no longer registers fails the suite. Full per-record
semantics live in the ledger table in
[ADDING_CUSTOM_NODES.md](ADDING_CUSTOM_NODES.md).
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 300}}}%%
flowchart TD
F["a node fails a tier"] --> Q1{"is EXECUTING it unsafe or environment-dependent?"}
Q1 -- yes --> L1["execution exclusion: never run; mechanism on record; every other tier still applies"]
Q1 -- no --> Q2{"does it fail deterministically on synthesized inputs?"}
Q2 -- yes --> L2["known-failure baseline: still runs every time; reconciled in both directions"]
Q2 -- no --> Q3{"does the pack's own script own the failing surface?"}
Q3 -- yes --> L3["scoped exception record naming the mechanism"]
Q3 -- no --> L4["no exception applies: it is a finding. Fix it or file it"]
```
What the first question means in practice: runtime downloads or installs,
infinite loops, host-specific results, mutable-content dropdowns,
unreliable completion signals. What a pack script owning the failing
surface looks like: rewritten values, custom widgets, vetoed wires,
console noise.
The two-way baseline is what stops the whole evidence model from rotting: a
failure that is not listed fails the gate, and a listed node that starts
passing ALSO fails the gate until its stale entry is removed. Exemptions
cannot silently accumulate.
Not every ledger can earn that two-way strength; the guards come in three
grades. Ledgers whose nodes still execute (the known-failure baseline) are
two-way behavioral: a new failure and a stale entry both flip the gate.
Ledgers that stop a path from running at all (execution exclusions,
probe-write exemptions) are registration guarded: the suite proves the
named node still exists, but the excluded path never runs, so an entry
that stopped being necessary cannot be observed; staleness there is
caught by review, not observation. Weakest are the pattern allowlists
(the console-error ledger): an entry that no longer matches anything
simply filters nothing, and usage tracking cannot be naively bolted on,
because some patterns are environment conditional (a missing-model 404
fires only on hosts without the model), so an entry can be legitimately
idle in one environment and load-bearing in the next.
The console-error ledger also has a bounded window, not just bounded
strength. Collection starts inside each tier, so it covers that tier's
own actions (load, run, wire, save); console noise a pack logs at app
boot, before the first tier action, is outside it - the shared app
fixture navigates once at setup, so boot output predates any per-pack
collector. This is deliberate: boot breakage that reaches a visible
surface is still caught by the startup zero-visible-errors check, and
invisible boot console noise is exactly what the ledger exists to
tolerate rather than gate on.
## 11. Design decisions
The decisions that define the suite, with their trade-offs. Each is
deliberate, and each is cheap to reverse or narrow later. The suite's one
deliberate extension seam is the curated-workflow fixture: anything the
manifest cannot derive from the live node corpus (pack-specific semantics,
multi-node behavior) is expressed there (decisions 6 and 11).
| # | Decision | Why | Trade-off accepted |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Drive a real browser, not just the backend API | Pack frontend scripts (widget rebuilds, restyles, submission hooks) are half of what breaks; only a browser running the built frontend exercises them | Browser e2e is the slowest, most race-prone tier; mitigated by the attribution filters (section 9) and the staged settle points (section 8) |
| 1 | Real environment only: real browser, real backend, pack scripts active, nothing mocked | The failures worth catching live in the integration, not in units | Slower than unit tests; needs a backend in CI |
| 2 | The backend serves the built frontend | The dev server never loads pack scripts, so it tests a different product | Local iteration needs a build + restart for pack-script changes |
| 3 | One test worker | The execution queue is exclusive; parallel workers corrupt each other's evidence | Wall-clock time grows with the manifest |
| 4 | Execution caching disabled | The per-node "actually ran" signal only exists for uncached executions | Every run pays full execution cost |
| 5 | Packs installed at pinned, verified versions | An upstream push must not change what the gate tests mid-flight | Pins need deliberate bumps; a nightly canary against pack HEADs is the planned complement |
| 6 | One manifest row per pack, zero per-pack test code | Extension cost stays constant as coverage grows | The generic tiers cannot assert pack-specific semantics; curated workflows exist for that |
| 7 | Both renderers only where the renderer can change the outcome: mount, persistence, the curated render pass, the curated pointer drags, core smoke; one renderer elsewhere (breadth wiring sweep, execution) | Widget values flow through the same store under both renderers (verified by probe), so doubling execution buys no new failure surface | If that store unification ever changes, revisit this decision |
| 8 | Every exception carries its mechanism and is stale-guarded | An unexplained exemption is indistinguishable from a hidden bug | Onboarding a flaky pack takes more effort than a blanket skip |
| 9 | Known-failure baseline reconciled in both directions | One-way baselines rot into permanent blind spots | A node that gets fixed upstream turns the gate red until its entry is removed (by design) |
| 10 | Small batches with single-node bisection | Batching amortizes queue latency; bisection restores per-node attribution on failure | A failing batch costs one extra pass over its members |
| 11 | Scope excludes output semantics and frontend-virtual nodes | Both need per-node knowledge a manifest cannot derive; curated workflows and future behavior tests are the extension point | "Green" is narrower than "the pack fully works," and says so |
## 12. Gotchas: every incident, its root cause, and the defense
These failure modes shaped the suite. Each was real: something passed that
should have failed, or failed for a reason that had nothing to do with the
node under test. Named nodes below are worked examples of their class,
kept because specifics are what make a mechanism checkable. Do not remove
a defense without re-reading its incident. The two recurring team concerns
these answer: "green but broken" and "tests can never catch random bugs."
### G1. Dev-server pack-script blindspot
- **You hit it when**: a node behaves perfectly in local dev but breaks on
CI, or vice versa, on any pack that restyles nodes, rebuilds widgets, or
hooks the submission path.
- **Root cause**: the dev server loads core extension scripts only; pack
frontend scripts never run under it. The node tested there is a
different node than the one users get.
- **Defense**: the environment contract (section 3): the backend serves the
built frontend and tests point at the backend. CI does exactly this (section 13).
- **Answers**: green but broken.
### G2. Widget-state bleed through recycled node identities
- **You hit it when**: a node fails validation with a value it was never
given, specifically a dropdown carrying an option that belongs to some
OTHER node created earlier in the same session.
- **Root cause**: the frontend keeps widget state keyed by node identity,
and that state survives clearing the graph. A new node that reuses a
cleared node's identity inherits its same-named widget values. Core
frontend bug, distinct from this suite; the defense below stands
regardless of when it is fixed.
- **Defense**: the suite never reuses a node identity within a browser
session: every builder hands out monotonically increasing identities
across graph clears.
- **Answers**: green but broken (a neighbor's leftover value produces a
false failure and hides the real store bug).
### G3. Event misattribution races
- **You hit it when**: node A is reported failing, but the error belongs to
node B tested just before it, or to a duplicate submission of an earlier
graph.
- **Root cause**: two races over the asynchronous event stream: late
arrivals from a previous attempt, and duplicate attempts created by a
submission retry erroring under a fresh identity.
- **Defense**: the positive submission-id match plus the graph-membership
filter of section 9, made decisive by G2's never-reuse-identities rule.
- **Answers**: tests can never catch random bugs (a misattributed error is
noise that erodes trust in every verdict).
### G4. Pack scripts crashing the submission path
- **You hit it when**: an entire pack's execution tier aborts, not just one
node.
- **Root cause**: pack scripts can hook workflow submission and throw on a
graph shape they do not expect. Observed example: a video pack's
"apply to graph" hook copies its latest file into downstream widget
inputs and throws when its output feeds a plain socket while matching
files exist; the trigger is content-dependent.
- **Defense**: submission runs guarded; a throw records as that node's
failure, carrying the exception text, so the node names itself instead
of aborting the tier. The proven case is also excluded with its
mechanism in the exclusion ledger, and remains an upstream-report
candidate.
- **Answers**: tests can never catch random bugs (uncaught, one crash masks
every node queued behind it).
### G5. Two definition dialects
- **You hit it when**: a set of nodes silently never executes: they are
classified as needing wires they do not need, so the planner skips them
and nothing goes red.
- **Root cause**: node definitions reach the suite in two dialects (legacy
list-form and V2 object-form), and a parser written against one dialect
misreads the other. Measured example: 8 nodes of one pack were invisibly
unexecuted until the classifier learned the second dialect.
- **Defense**: each consumer's parser handles both dialects
(`declaredShape` for mount, `classifyInput` for execution, the
normalizer for wiring; section 4); parser fixtures are copied from a
live census of the real corpus so tests cannot self-confirm a parser's
assumptions; unknown shapes are excluded with a record, never silently
matched (section 6).
- **Answers**: green but broken (a whole class of nodes was uncovered while
the tier stayed green).
### G6. "Must be wired" beats every dialect
- **You hit it when**: an input the pack marked as wire-only is treated as
a widget, so the node runs without the wire it requires.
- **Root cause**: the wire-only flag can appear on any input form; a
classifier that checks the form before the flag misreads it.
- **Defense**: the classifier checks the wire-only flag first, before any
form-specific branch; fixtures pin the ordering.
- **Answers**: green but broken.
### G7. Dropdown pairing semantics
- **You hit it when**: the wiring tier pairs two unrelated dropdowns (a
checkpoint list into a scheduler list), a pass that proves nothing, or
refuses to pair two dropdowns that differ only in menu order.
- **Root cause**: a wired dropdown input bypasses its own menu, so the wire
contract is set membership of options, not their order. And dropdowns
whose options are not statically known cannot prove anything by pairing.
- **Defense**: dropdowns pair only on identical option SETS
(order-insensitive); dropdowns with unknown option lists are excluded
from pairing with a record instead of blind-matched.
- **Answers**: green but broken.
### G8. Environment flips
- **You hit it when**: a node fails on one OS but is clean on another, run
to run, with no code change. A subtle variant is the warm-cache
illusion: a node that downloads model weights inside execution runs
clean only where the cache is already warm.
- **Root cause**: execution depends on the host, not on the node's
frontend contract: numeric-stack differences, codec differences, cached
downloads, directory-handling differences.
- **Defense**: the environment-variable class of execution exclusions,
each entry naming its per-host mechanism, reconciled against observation
runs on both hosts. The node keeps every non-execution tier.
- **Answers**: tests can never catch random bugs (host-dependent flips are
flake that trains people to ignore red).
### G9. Queue jams from non-interruptible execution
- **You hit it when**: the execution tier hangs and every node queued
BEHIND one offender reports failure.
- **Root cause**: some execution paths never respond to interrupt:
installing packages at runtime, pure-Python infinite loops (observed
example: a text-replace node spinning forever on an empty search
string), minutes-long per-pixel loops, non-interruptible weight
downloads.
- **Defense**: a timeout interrupts and checks that the queue recovers; a
queue that will not drain stops the tier immediately and names the batch
as suspects. Triage is explicitly offender-versus-victims, and a
preflight asserts the queue is idle before the tier starts. Proven
offenders are excluded with their mechanism.
- **Answers**: tests can never catch random bugs (a jam failing a whole
batch is pure noise; the tripwire converts it into one named offender).
### G10. Renderer effect timing
- **You hit it when**: the persistence tier passes under the canvas
renderer but silently tests nothing under the DOM renderer.
- **Root cause**: DOM-renderer widget components react to creation and
reload asynchronously, writing back into the value store on frame
boundaries; a synchronous snapshot compares state those reactions never
touched.
- **Defense**: the persistence check is staged with explicit settle points
between build, snapshot, reload, and write phases (section 8), and runs once
per renderer.
- **Answers**: green but broken (a synchronous pass certifies a value path
it never observed).
### G11. Growable input groups materialize under expanded names
- **You hit it when**: mount completeness reports a declared input missing
on a node that uses growable input groups, when the renderer actually
materialized it under expanded per-slot names.
- **Root cause**: growable input groups do not materialize under their
declared group name; they expand into per-slot names derived from it.
- **Defense**: mount expectations accept either the group name or its
required expansion; this was the only definition-shape special case
found across the full corpus.
- **Answers**: keeps mount fidelity strict without false-failing
group-typed nodes.
### G12. Legal dynamic growth on reload
- **You hit it when**: a node legitimately gains a widget on reload (the
application attaches a seed-control widget; a pack appends a
value-driven widget) and a naive equality check flags it as a
regression.
- **Root cause**: reload is allowed to APPEND; what must never happen is
the inverse: a widget disappearing or a saved value changing.
- **Defense**: the persistence comparison is asymmetric by design: growth
passes, loss or mutation fails; after probe writes, values are compared
only where the node's shape stayed stable, because a changed dropdown
can legally rebuild a dynamic node.
- **Answers**: green but broken, from the other side: a check that
rejected legal growth would get relaxed into uselessness.
### G13. Mutable-content dropdowns
- **You hit it when**: a file-list node flips between clean and failing
across runs, tracking whatever content the backend happens to hold.
- **Root cause**: some dropdowns populate from mutable backend content
(file listings, run history), so their default value and validity change
as content changes.
- **Defense**: the state-dependent class of execution exclusions, with the
mechanism on record; where the same dropdown also re-resolves on reload,
a scoped persistence exception skips the value comparison while the
no-shrink rule still applies. All other tiers are retained.
- **Answers**: tests can never catch random bugs.
### G14. Unreliable completion signals
- **You hit it when**: a node reports clean on one run and incomplete on
the next with no change to anything.
- **Root cause**: the per-node "actually ran" signal is reliable for
ordinary nodes with caching disabled, but list-expanded and
remote-control nodes do not emit it on every run.
- **Defense**: only nodes with a PROVEN signal flip are excluded from
execution, each recorded with the shared mechanism, so an incomplete
result stays meaningful everywhere else.
- **Answers**: tests can never catch random bugs.
## 13. The CI deployment view
In today's implementation, the suite is Playwright driving bundled
Chromium, and the CI platform is GitHub Actions.
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 260}}}%%
flowchart LR
CH["change gate: skip only when nothing relevant changed, without wedging the required check"] --> BUILD["build the frontend"]
BUILD --> ENV["provision a CPU backend"]
ENV --> INST["clone every manifest pack at its pinned version; install with dependency constraints so packs cannot swap the numeric stack"]
INST --> ASSET["stage the curated workflows' media"]
ASSET --> RUN["boot the backend serving the built frontend; run the suite, one worker"]
RUN --> SKIP{"anything skipped?"}
SKIP -- yes --> RED["fail: a pack or a fixture failed to load"]
SKIP -- no --> ART["publish the report artifact"]
```
Fork PRs skip the job (the install loop is a code-execution surface) and
keep coverage via the main test shards. Sharding is deliberately deferred:
every shard would pay the full environment setup, which is a large share of
the job; the workflow states the threshold at which sharding starts paying.
Ballpark at the time of writing, moving like the scale snapshot: about
eight minutes of suite on top of about four and a half minutes of
environment setup, with sharding starting to pay once the whole job
passes roughly twelve minutes.
## 14. Implementation map
The one place where architecture names meet code symbols.
| Building block | File | Key symbols |
| ------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pack Manifest | `browser_tests/fixtures/data/customNodeManifest.json` | one row per pack: `pack`, `repo`, `pin`, `tiers`, `workflow`, `expectedNodes`, `requiresGpu`, `requiresModels`, `timeoutMs`, plus optional `vueNodesCompatible`, `vueIncompatibleNodes`, `cannotRunAlone` |
| Manifest loader | `browser_tests/fixtures/customNode/manifest.ts` | `loadManifest`, `rendererPassesFor` |
| Test Orchestrator | each spec file | the `for (const entry of loadManifest())` loop heading allNodes.spec.ts, connectivity.spec.ts, customNode.regression.spec.ts |
| Evidence Ledgers + Reconciler | `browser_tests/tests/customNodes/allNodes.spec.ts`, `connectivity.spec.ts` | the `*_ALLOWLIST` maps, `AUTO_RUN_EXCLUDE`, the `cannotRunAlone` two-way reconciliation, stale-entry guards |
| Definition Normalizer | `browser_tests/fixtures/customNode/typePairing.ts` | `normalizeNodeDefs`, `packOf` |
| Wiring planner | `browser_tests/fixtures/customNode/typePairing.ts` | `planPairs`, `isTypeCompatible`, `vocabOf` |
| Capability Classifier | `browser_tests/fixtures/customNode/autoRun.ts` | `classifyAutoRunnable`, `classifyInput`, `planAutoRuns`, `batchAutoRunnable`, `SYNTH_PRODUCERS` |
| Execution Harness | `browser_tests/fixtures/customNode/ComfyTarget.ts` | `LocalDesktopTarget.runWorkflow`: event tap, attempt + graph-membership filters, guarded submission |
| Outcome classification | `browser_tests/fixtures/customNode/runResult.ts` | `classifyRun`, `CustomNodeOutcome` |
| Mount / Persistence / Execution tiers | `browser_tests/tests/customNodes/allNodes.spec.ts` | `addChunk`, `declaredShape`, the staged rig on `window.__cnRt`, `runBatch`, monotonic identities via `window.__cnIdBase`, five in-spec exception ledgers |
| Wiring tier | `browser_tests/tests/customNodes/connectivity.spec.ts` | breadth sweep, executor self-check, curated drags, two allowlists |
| Curated workflows + self-check | `browser_tests/tests/customNodes/customNode.regression.spec.ts` | T0/T1 per pack, forced-error positive control |
| Core smoke | `browser_tests/tests/customNodes/coreSmoke.spec.ts` | |
| Parser/classifier fixtures | `browser_tests/tests/customNodes/*.pure.spec.ts` | census-derived cases for both definition dialects |
| CI job | `.github/workflows/ci-tests-custom-nodes.yaml` | gating check `custom-nodes-e2e` |

View File

@@ -0,0 +1,190 @@
# Detection Proof
How we prove the custom-node regression suite actually catches every failure
mode it claims to in [ARCHITECTURE.md](ARCHITECTURE.md). The proof is a
separate, deliberately-red pull request branched off the suite branch: each
commit breaks one surface on purpose, cites the real regression class it
recreates, and turns the custom-nodes CI check red at exactly the named tier
with the named message. (A frontend break may also trip other layers, e.g.
unit tests - that is layered coverage, not noise.) A green custom-nodes check
anywhere in that PR would mean the gate failed to catch a regression.
This replaces the earlier ad-hoc "kill-test" name. The verb is **falsify**: we
falsify each guard by breaking the thing it watches and confirming it fires.
## Why this exists
The suite's value claim is that a frontend PR can no longer silently break a
widely-installed custom-node pack. That claim is only worth as much as its
ability to go red on a real break. A green suite proves nothing on its own -
it could be green because everything works, or green because it checks nothing.
The Detection Proof PR removes that doubt: it shows, break by break, that every
tier in ARCHITECTURE.md turns red on the exact class of regression it was built
to catch, and names the offender in the failure message.
## How to read the proof PR
- **It must never merge.** Every commit is a deliberate break. A reviewer reads
it, they do not ship it.
- **One commit per surface.** Each commit is a single-file change plus a comment
naming the historical regression it recreates and the red it should produce.
Check out a commit, watch the named CI check go red, read the message, move on.
- **CI is the source of truth, not a local full run.** The CI job runs the
suite against one fresh backend on an unloaded runner, which keeps every
execution inside its budget. A local run of the whole
suite against a single CPU backend is not reliable for this (see
[Honest caveat](#honest-caveat-local-full-runs-and-machine-load)); run CI, or
run one pack locally at a time.
## Two protection modes
The gate protects against two distinct things, and the proof covers both:
- **FE-regression** - a change to _this frontend_ breaks installed packs. This
is the primary thing the gate guards on every frontend PR. These breaks live
in `src/`.
- **Pack-bug** - a pack itself ships a bug (or a pinned pack is bumped to a
broken version). The gate catches these too. CI clones every pack fresh at
its pin, so editing pack files in the frontend repo does nothing - the clone
overwrites them. Two ways deliver a pack break on CI: (a) point the manifest
(`browser_tests/fixtures/data/customNodeManifest.json`) `repo`/`pin` at a
broken fork, which is exactly the pinned-bump scenario and the most
production-faithful; or (b) a self-contained CI step that patches each cloned
pack in place right after install. The proof PR uses (b) - no external repos,
and each patch asserts it landed (`grep`, fails the job otherwise) so a silent
no-op cannot fake a pass. Both reproduce the same edits captured against a
local backend (which is how the exact reds below were captured).
Each row below is labelled with its mode.
## The correlation matrix
Every "Exact red" below is the real message captured when the break was applied
and the tier was run against a real backend - not a prediction. One scope note:
for the corpus-derived tiers (rows 4, 6, 9) the named offender and pair list
are re-derived from `/object_info` each run, so a pin bump can legitimately
change WHICH pair or node the message names without weakening the catch - the
promise is the tier and the failure class, not byte-identical offender text
across pin changes. Sections refer to [ARCHITECTURE.md](ARCHITECTURE.md).
| # | Surface (ARCH section) | Mode | Real regression it recreates | The one-file break | CI check that catches it | Exact red |
| --- | ------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| 1 | Mount completeness, canvas / v1 (s1, s5) | FE | A change dropping declared parts on the canvas renderer (class; no single ticket - the v2 wave below shows how this family presents) | `src/services/litegraphService.ts` `addInputs`: stop materializing the last declared input | Tests Custom Nodes / mount tier | `BatchCount+: instance is missing declared input "batch" (litegraph)` |
| 2 | Mount completeness, DOM / v2 (s1, s5) | FE | Widgets missing under Nodes 2.0 (FE-627/FE-634 iTools buttons; FE-841 is the adjacent wrong-style class, present but unproven caught) | `src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts`: drop the `int` widget component mapping | Tests Custom Nodes / mount tier (Vue pass) | `Ideogram4PromptBuilderKJ: Vue mounts 9 of 15 widgets` |
| 3 | Persistence, save/reload (s1, s8) | FE | Widgets reverting to socket-only on reload: the defaultInput migration regression that PR #12279 (open) exists to fix | `src/lib/litegraph/src/LGraphNode.ts` `configure`: off-by-one drops the last `widgets_values` entry | Tests Custom Nodes / persistence tier | `Seed (rgthree): widgets_values [1,"fixed"] -> [1,"randomize"] on set-values reload` |
| 4 | Wiring - type compatibility (s5, s6) | FE | A frontend change narrowing connectable types (class; no single verified ticket) | `src/lib/litegraph/src/LiteGraphGlobal.ts` `isValidConnection`: reject IMAGE links | Tests Custom Nodes / connectivity sweep | `AddLabel.IMAGE -> FastPreviewBatch.input: CONNECT_REJECTED` (full pair list) |
| 5 | Wiring - drop resolution (s5) | FE | Drag/slot resolution family (nearest reported symptoms: FE-625/FE-632 EditUtils connections shift after drag) | `src/lib/litegraph/src/canvas/measureSlots.ts` `getNodeInputOnPos`: return undefined | Tests Custom Nodes / connectivity drag | `EmptyImage.IMAGE -> ImageBatch.image2 with VueNodes=false` |
| 6 | Execution - frontend prompt serialization (s7) | FE | A prompt-serialization change corrupting inputs (class; no single verified ticket) | `src/utils/executionUtil.ts`: drop numeric widget values from the API prompt | Tests Custom Nodes / curated run (T1) | `Prompt outputs failed validation; ImpactInt: value; ImpactFloat: value` |
| 7 | Zero-visible-errors / load hook (s1) | FE | An extension hook crashing on graph load, the mechanism packs hook (FE-751 class; the break is in a core extension, hence FE mode) | `src/composables/node/useNodeBadge.ts` `afterConfigureGraph`: throw | Tests Custom Nodes / curated run (T1) | `Error calling extension 'Comfy.NodeBadge' method 'afterConfigureGraph' ...` |
| 8 | Console / pageerror ledger (s10) | Pack | An uncaught pack-JS error during save/reload (the betterCombos.js `typeof null` bug this suite found) | CI step patches the cloned ComfyUI-Custom-Scripts `showText.js` to log a `console.error` in `onConfigure` (captured locally by editing the installed pack directly) | Tests Custom Nodes / curated run (T1) | `console errors during curated run` + the exact text + script URL |
| 9 | Execution - runtime (s7) | Pack | A pack node raising at execution (WAS Text Find/Replace infinite loop; KJ ImageGridtoBatch min violation) | CI step patches the cloned was-node-suite `return_constant_number` to raise on entry (captured locally by editing the installed pack directly) | Tests Custom Nodes / auto-run tier | `Constant Number: EXECUTION_ERROR (Constant Number: ValueError) - not in cannotRunAlone; a regression, ...` |
| 10 | Registration / expectedNodes sentinels (s5, s10) | Pack | A pinned pack bump renaming a node key | CI step patches the cloned ComfyUI-Impact-Pack `__init__.py` to rename the `ImpactInt` mapping key (captured locally by editing the installed pack directly) | Tests Custom Nodes / zero-skip gate | job goes red on `skipped != 0` (T0 + T1 skip; the workflow's "Forbid skipped tests" step fails) |
### Links of various types (surface 4/5 expanded)
"Links of various types" is covered breadth-first: the connectivity tier
plans one representative typed edge per slot across the whole installed corpus,
so a single break in the validator (#4) fails a broad, named list of concrete
pairs - not one hand-picked wire. The drag break (#5) additionally proves the
_pointer_ path resolves the exact slot. To show breadth explicitly, the proof PR
can add two more validator mutations, each turning a different link class red:
- Break the COMBO option-vocabulary compare (`vocabOf`) - the committed pure
specs (typePairing.pure.spec.ts, same-vocabulary pairing tests) go red;
dropdown slots are checked, not just primitive types.
- Break the wildcard exclusion (`isWildcard`) - the committed pure specs
("wildcard slots are excluded" test) go red; the exclusion is pinned as a
design decision, not an accident. Both catches are at the pure-spec layer;
whether the live corpus also exercises them per run is not asserted here.
### Execution of various types (surface 6/7/9 expanded)
Three distinct execution break-points, each caught by a different tier:
- **Frontend serialization** (#6) - the value never leaves the browser correctly;
caught at submit as a named `VALIDATION_FAIL`.
- **Load-time hook** (#7) - an extension hook crashes the graph load (the same
hook mechanism pack scripts use); caught by the console/pageerror ledger.
- **Backend runtime** (#9) - the node runs and raises; caught by the auto-run
tier's two-way baseline, which isolates each node (single-node re-run) so
the failing node names itself; a chain that fails because its synthesized
producer raised still carries that producer's name in the backend's error
event.
## What is already proven (the falsification pass)
Before writing this plan, every break in the matrix was applied one at a time
against a real backend and the tier was confirmed to catch and name it. That is
where the "Exact red" column comes from. Two of those runs also corrected the
suite itself, and those fixes are already committed on the suite branch:
- **Drag drop-resolution (#5)** was originally a _miss_: the curated drag test
only targeted first-slot inputs, and a broken drop resolver falls back to the
first compatible input (LinkConnector's drop-on-node path), so such a
regression could not fail a first-slot-only pair. Fixed by adding the
second-slot anchor (`EmptyImage.IMAGE -> ImageBatch.image2`); the matrix red
above is from the fixed test.
- **Curated-run failure naming (#6)** originally reported `{}` for a backend
validation rejection. Fixed by capturing and flattening the backend
`node_errors`; the matrix now shows the named nodes and input.
- **Boot-time console noise** was confirmed out of the ledger's window by
design (documented in ARCHITECTURE.md section 10 and README), backstopped by
the startup zero-visible-errors check.
## Honest caveat: local full runs and machine load
All tests share ONE backend, locally and on CI alike (the CI job is
deliberately unsharded), and the suite enforces per-test backend isolation
itself: every test's
afterEach drains the backend to idle (`drainBackendToIdle`), the auto-run tier
waits out a still-draining prior execution instead of hard-failing, and the
non-executing tiers filter a foreign execution's async console lines
(`isForeignExecutionNoise`). This fixed the cross-test bleed class outright: a
test can no longer leave work running for the next test to inherit, and the
mount/persistence/wiring tiers no longer catch a neighbor's execution errors.
What remains genuinely load-sensitive is execution TIMING, not isolation: on a
machine that is busy with other work, slow CPU nodes can exceed even the raised
budgets (20s batch, 60s single re-run), which flips their classification and
trips the two-way cannotRunAlone baseline. That is the baseline doing its job
against an environment that changed under it, not a suite defect. Therefore:
- Use **CI** as the pass/fail oracle for the Detection Proof (a fresh backend
on an unloaded runner, every run).
- A local full run is meaningful on an otherwise-idle machine; do not run it
concurrently with heavy local work and expect baseline-exact results.
## Building the proof PR
1. Branch off the suite branch: `git checkout -b nathaniel/detection-proof nathaniel/custom-node-e2e-suite`.
2. One commit per matrix row, each breaking one surface, stacked so all breaks
are live at HEAD at once (not reverted between commits - the goal is to see
every surface broken together, and the `Tests Custom Nodes` job reds across
every tier in one run). FE-mode rows (1-7) are a direct `src/` edit carrying
an inline comment in the changed file:
`// DETECTION PROOF (row N, surface): recreates <FE-xxx / PR #12279>. Expected: <tier> red <message>.`
3. Pack-mode rows (8-10) are delivered by one CI step
(`DETECTION PROOF - break packs`, on this branch only) that patches each
cloned pack in place right after install. Each patch asserts it landed
(`grep`, fails the job otherwise) so a silent no-op cannot fake a pass. The
step is fenced to this never-merge branch and must never be ported to a real
suite branch.
4. Commit message names the surface, e.g.
`detection-proof: break mount (v2 Vue renderer) - drops the int widget mapping`.
5. Open the PR against the suite branch (not main) with the correlation matrix as
the description and a bold header: **This PR must never merge. Every commit is
a deliberate break; green would mean the gate missed a regression.**
6. Let CI run on HEAD. With every break live, the `Tests Custom Nodes` job reds
across every tier in one run. Attribute a red to its cause via the labelled
comment on the matching `src/` file (rows 1-7) or in the CI break step
(rows 8-10); checking out commit N (which contains breaks 1..N) narrows it
further.
## References
- Linear "Custom Node Bugs" project issues (symptoms): FE-841, FE-627, FE-634,
FE-630, FE-637, FE-629, FE-625, FE-632, FE-751, FE-489, FE-491, FE-492.
- The defaultInput migration regression (widgets revert to socket-only on reload) and its open fix: Comfy-Org/ComfyUI_frontend #12279.
- Suite-discovered bugs with no upstream ticket yet (betterCombos `typeof null`,
WAS infinite-loop, WAS pip-install-in-execute, KJ ImageGridtoBatch min) are
pending upstream filing.

View File

@@ -0,0 +1,132 @@
# 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.
System design, data flow, and the reasoning behind every invariant:
[ARCHITECTURE.md](ARCHITECTURE.md). Onboarding a new pack:
[ADDING_CUSTOM_NODES.md](ADDING_CUSTOM_NODES.md).
## 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 against the Vite dev server - the fast local loop for suite-code iteration. NOT the gate: the dev server never loads pack frontend JS (see Gotchas) |
| `pnpm test:custom-nodes:ci` | whole suite headless against the backend-served BUILT frontend - the gate-equivalent run (every tier passes, zero skips). Requires a backend serving the built dist on :8188 (a separate endpoint from the :8288 dev-proxy backend in Prerequisites); set `PLAYWRIGHT_TEST_URL` if yours differs |
| `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 measured 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_OBSERVABLE_OUTPUT` / 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**: the mount, persistence, connectivity, core
smoke, and curated workflow tests assert the app's error surfaces (error
overlay, error dialog, node render errors, error toasts) are absent at
start and after every pass - green means a human watching those runs sees
no errors. Two deliberate exceptions: the auto-run execution tier
provokes expected failures (baselined cannotRunAlone nodes surface as
real error UI by design), and the self-check inverts the invariant - it
forces a real execution error and asserts the overlay IS visible, proving
the selectors stay live.
- **Console-error window**: the console/page-error ledger (curated run,
save/reload) starts collecting inside each tier, so it covers the tier's
own actions - load, run, wire, save. Pure console noise a pack logs at
app boot, before the first tier action, is out of that window by design:
the shared app fixture navigates once at setup, so boot output predates
any per-pack collector. Boot breakage that MATTERS still fails the gate -
the zero-visible-errors check runs at startup and catches any boot error
that reaches a visible surface; only invisible, functionally-inert boot
console noise (the ledger's whole reason to exist) is out of scope.
## 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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,238 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
batchAutoRunnable,
classifyAutoRunnable,
planAutoRuns
} from '@e2e/fixtures/customNode/autoRun'
const SYNTH = new Set([
'IMAGE',
'LATENT',
'MASK',
'INT',
'FLOAT',
'STRING',
'BOOLEAN',
'*'
])
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
},
SYNTH
)
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
},
SYNTH
)
expect(verdict.verdict).toBe('AUTO_RUNNABLE')
expect(verdict.needsPreviewSink).toBe(false)
})
test('synthesizable sockets make a node CHAINABLE with its socket list', () => {
const verdict = classifyAutoRunnable(
'MaskComposite',
{
input: {
required: {
destination: ['MASK'],
source: ['MASK'],
x: ['INT', { default: 0 }],
operation: [['multiply', 'add']]
}
},
output: ['MASK'],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('CHAINABLE')
expect(verdict.requiredSockets).toEqual([
{ name: 'destination', type: 'MASK' },
{ name: 'source', type: 'MASK' }
])
expect(verdict.needsPreviewSink).toBe(true)
})
test('a socket with no model-free producer means NEEDS_WIRES', () => {
const verdict = classifyAutoRunnable(
'VaeDecode',
{
input: { required: { samples: ['LATENT'], vae: ['VAE'] } },
output: ['IMAGE'],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('NEEDS_WIRES')
expect(verdict.reason).toContain('vae')
})
test('forceInput STRING is a socket but STRING is synthesizable', () => {
const verdict = classifyAutoRunnable(
'TextSink',
{
input: { required: { text: ['STRING', { forceInput: true }] } },
output: ['STRING'],
output_node: true
},
SYNTH
)
expect(verdict.verdict).toBe('CHAINABLE')
expect(verdict.requiredSockets).toEqual([{ name: 'text', type: 'STRING' }])
})
test('an empty required combo means NEEDS_MODELS', () => {
const verdict = classifyAutoRunnable(
'CheckpointLoader',
{
input: { required: { ckpt_name: [[]] } },
output: ['MODEL'],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('NEEDS_MODELS')
expect(verdict.reason).toContain('ckpt_name')
})
// Census-derived: transformed (V2-schema) defs carry combos as the string
// 'COMBO' with options in the opts object - the classifier must not read
// that as an unproducible socket type.
test('a V2-form combo with options is a widget', () => {
const verdict = classifyAutoRunnable(
'LatentConcatLike',
{
input: {
required: {
dim: ['COMBO', { multiselect: false, options: ['x', '-x', 'y'] }]
}
},
output: ['LATENT'],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('AUTO_RUNNABLE')
})
// Census-derived (DevToolsNodeWithOutputCombo.subset_options): a combo
// carrying forceInput is a socket in ANY form - no widget materializes,
// so its option list cannot satisfy the input.
test('forceInput on a list-form combo is a socket, not a widget', () => {
const verdict = classifyAutoRunnable(
'OutputComboLike',
{
input: {
required: { subset_options: [['A', 'B'], { forceInput: true }] }
},
output: ['COMBO'],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('NEEDS_WIRES')
expect(verdict.reason).toContain('subset_options')
})
test('a V2-form combo with no static options means NEEDS_MODELS', () => {
for (const spec of [
['COMBO', { multiselect: false, options: [] }],
['COMBO', { remote: { route: '/internal/files/output' } }]
]) {
const verdict = classifyAutoRunnable(
'LoadImageOutputLike',
{
input: { required: { image: spec } },
output: ['IMAGE'],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('NEEDS_MODELS')
expect(verdict.reason).toContain('image')
}
})
test('no outputs and not an OUTPUT_NODE means NO_OBSERVABLE_OUTPUT', () => {
const verdict = classifyAutoRunnable(
'SideEffectOnly',
{
input: { required: { value: ['INT', {}] } },
output: [],
output_node: false
},
SYNTH
)
expect(verdict.verdict).toBe('NO_OBSERVABLE_OUTPUT')
})
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
},
SYNTH
)
expect(verdict.verdict).toBe('AUTO_RUNNABLE')
})
test('planAutoRuns validates producers against defs and batches runnables', () => {
const defs = {
A: {
input: { required: { v: ['INT', {}] } },
output: ['INT'],
output_node: false
},
B: {
input: { required: { x: ['SEGS'] } },
output: ['SEGS'],
output_node: false
},
C: {
input: { required: { img: ['IMAGE'] } },
output: ['IMAGE'],
output_node: false
},
EmptyImage: { input: { required: {} }, output: ['IMAGE'] }
}
const verdicts = planAutoRuns(defs, ['A', 'B', 'C'])
expect(verdicts.map((verdict) => verdict.verdict)).toEqual([
'AUTO_RUNNABLE',
'NEEDS_WIRES',
'CHAINABLE'
])
const batches = batchAutoRunnable(verdicts, 1)
expect(batches.map((batch) => batch[0].key)).toEqual(['A', 'C'])
})
})

View File

@@ -0,0 +1,546 @@
import type { Page } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
customNodeSuiteSettings,
dismissTemplatesDialog,
drainBackendToIdle
} from '@e2e/fixtures/utils/customNodeSuite'
import { isForeignExecutionNoise } from '@e2e/fixtures/customNode/consoleErrorLedger'
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 links it manages itself
// (reproducible manually: wire, save, reload - link gone). 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)
})
// Leave the shared backend idle so the next test starts clean (drainBackendToIdle).
test.afterEach(async ({ comfyPage }) => {
// The drain is a no-op when the queue is already idle, so it costs
// ~nothing in the common path; the 10s ceiling only bounds a genuinely
// busy backend. A backend still busy past it is wedged, and the auto-run
// tier's 150s guard surfaces that with the restart diagnostic.
await drainBackendToIdle(comfyPage.page, 10_000)
})
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`)
// Corpus = every node the installed packs register, from the live backend.
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), ${plan.unknownShapes.length} unknown-shape slots (recorded: ${plan.unknownShapes.join('; ') || 'none'})`
)
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)
}
// The breadth sweep runs under one renderer by design: it exercises
// graph-API link creation, the real isValidConnection veto, and
// serialize/configure survival - all renderer-independent paths (widget
// values and links flow through the same stores in both renderers). The
// curated drag test below covers real pointer wiring under BOTH renderers.
const consoleErrors = collectConsoleErrors(comfyPage.page)
const results = await runPairsInPage(comfyPage.page, plan.pairs)
consoleErrors.stop()
// Deliberately raw, not routed through the pack console ledger
// (consoleErrorLedger.ts): the sweep holds zero console errors without
// exceptions today, and the stricter contract catches noise the moment
// wiring provokes it. If a ledgered pattern ever fires here, filter
// through unallowlistedErrors with the pack taken from the offending
// pair's nodes (the sweep is cross-pack), instead of silently
// loosening this assert. The wiring sweep queues no prompts, so a
// prompt-execution error here is a prior tier's async stray, not this
// test's (isForeignExecutionNoise; ARCHITECTURE section 9 principle).
expect(
consoleErrors.errors.filter((error) => !isForeignExecutionNoise(error)),
'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)
// Two-way guard, same discipline as cannotRunAlone: every allowlisted key
// must still be OBSERVED failing in its recorded way. An entry whose pair
// now passes (or is no longer even planned) is stale and would silently
// hide the fixed bug behind it. On a partially-installed local backend an
// absent key only logs; CI installs every pack, so it always enforces.
const outcomeByKey = new Map(
results.map((result) => [result.key, result.outcome])
)
const allPacksInstalled =
installedEntries.length === connectivityEntries.length
const staleEntries: string[] = []
for (const [allowlist, expected] of [
[CONNECT_REJECTED_ALLOWLIST, 'CONNECT_REJECTED'],
[ROUNDTRIP_LOST_ALLOWLIST, 'ROUNDTRIP_LOST']
] as const)
for (const key of allowlist) {
const observed = outcomeByKey.get(key)
if (observed === undefined && !allPacksInstalled) {
console.log(
`allowlist entry not observed (pack not installed here): ${key}`
)
continue
}
if (observed !== expected)
staleEntries.push(
`${key}: expected ${expected}, observed ${observed ?? 'nothing'} - remove the stale entry`
)
}
expect(staleEntries, 'stale allowlist entries').toEqual([])
await expectNoVisibleErrors(comfyPage.page, 'after breadth sweep')
})
// First planned pair whose slots both exist on real 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) {
// Pack JS may rebuild a declared input as widget-only (rgthree
// Seed.seed) - excluded; missing as slot AND widget stays a 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'
}
},
// Second-slot anchor: ImageBatch has two IMAGE inputs (image1, image2)
// and we target the SECOND. A slot hit-test regression that falls back
// to the first compatible input would land on image1, leaving image2
// (the asserted index) unlinked - so this pair, unlike a first-slot
// pair, actually discriminates a broken drop-to-slot resolution.
{
producer: {
nodeType: 'EmptyImage',
pack: 'core',
slotName: 'IMAGE',
slotType: 'IMAGE'
},
consumer: {
nodeType: 'ImageBatch',
pack: 'core',
slotName: 'image2',
slotType: 'IMAGE'
}
}
]
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.filter((error) => !isForeignExecutionNoise(error)),
`console errors with VueNodes=${vueNodesEnabled}`
).toEqual([])
await expectNoVisibleErrors(
comfyPage.page,
`after drag pass VueNodes=${vueNodesEnabled}`
)
}
})

View File

@@ -0,0 +1,69 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import {
isForeignExecutionNoise,
unallowlistedErrors
} from '@e2e/fixtures/customNode/consoleErrorLedger'
// unallowlistedErrors is the sole enforcement point of the curated-run
// console gate (customNode.regression.spec.ts T1): a degradation to
// "always empty" would turn that gate vacuously green, so the filter's
// three behaviors are pinned here directly.
test.describe('consoleErrorLedger', () => {
test('filters only errors matching the pack own patterns', () => {
const errors = [
'Failed to load resource: the server responded with a status of 404 () http://host/example.png',
'TypeError: something real broke'
]
expect(unallowlistedErrors('ComfyUI-Impact-Pack', errors)).toEqual([
'TypeError: something real broke'
])
})
test('a pattern never filters for a pack that does not own it', () => {
const error = "Cannot use 'in' operator to search for 'content' in null"
expect(unallowlistedErrors('ComfyUI-Impact-Pack', [error])).toEqual([error])
expect(unallowlistedErrors('ComfyUI-Custom-Scripts', [error])).toEqual([])
})
test('unknown pack fails open: every error surfaces', () => {
// The first error would match an Impact pattern; with no ledger for the
// pack, nothing may be filtered.
const errors = [
'Failed to load resource: the server responded with a status of 404 () http://host/example.png',
'boom'
]
expect(unallowlistedErrors('Some-Future-Pack', errors)).toEqual(errors)
})
})
// Filters a prior tier's async execution error out of the non-executing
// tiers; must match execution-domain lines and nothing a mount/wiring tier
// should legitimately catch.
test.describe('isForeignExecutionNoise', () => {
test('matches the execution-domain console surfaces', () => {
expect(isForeignExecutionNoise('PromptExecutionError: boom')).toBe(true)
expect(isForeignExecutionNoise('Prompt execution failed')).toBe(true)
expect(
isForeignExecutionNoise(
'Failed to load resource: the server responded with a status of 400 (Bad Request) http://127.0.0.1:8288/api/prompt'
)
).toBe(true)
})
test('does not match render or unrelated resource errors a tier must catch', () => {
expect(
isForeignExecutionNoise('TypeError: cannot read x of undefined')
).toBe(false)
expect(
isForeignExecutionNoise(
'Failed to load resource: 404 http://127.0.0.1:8288/api/view?filename=x.png'
)
).toBe(false)
expect(
isForeignExecutionNoise('Uncaught page error: something rendered wrong')
).toBe(false)
})
})

View File

@@ -0,0 +1,71 @@
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 { isForeignExecutionNoise } from '@e2e/fixtures/customNode/consoleErrorLedger'
import {
customNodeSuiteSettings,
dismissTemplatesDialog,
drainBackendToIdle
} 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)
})
// Leave the shared backend idle so the next test starts clean (drainBackendToIdle).
test.afterEach(async ({ comfyPage }) => {
// The drain is a no-op when the queue is already idle, so it costs
// ~nothing in the common path; the 10s ceiling only bounds a genuinely
// busy backend. A backend still busy past it is wedged, and the auto-run
// tier's 150s guard surfaces that with the restart diagnostic.
await drainBackendToIdle(comfyPage.page, 10_000)
})
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)
// Core smoke loads a graph but queues no prompt; a prompt-execution
// error here is a prior tier's async stray (isForeignExecutionNoise).
expect(
consoleErrors.errors.filter((error) => !isForeignExecutionNoise(error)),
`console errors (VueNodes=${vueNodesEnabled})`
).toEqual([])
for (const [surface, locator] of Object.entries(
errorSurfaces(comfyPage.page)
))
await expect(
locator,
`${surface} (VueNodes=${vueNodesEnabled})`
).toHaveCount(0)
}
})
})

View File

@@ -0,0 +1,317 @@
/* 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,
drainBackendToIdle
} from '@e2e/fixtures/utils/customNodeSuite'
import { LocalDesktopTarget } from '@e2e/fixtures/customNode/ComfyTarget'
import {
isForeignExecutionNoise,
unallowlistedErrors
} from '@e2e/fixtures/customNode/consoleErrorLedger'
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
// Display sinks used by the curated workflows; each is an output node whose
// `executed` event carries a ui payload, so "the workflow ran" can be
// upgraded to "data actually arrived at the sink". Console-style sinks
// (WAS `Text to Console`) emit NO ui payload and stay off this list, so a
// pack whose only sink prints to console gets execution-completed proof
// only.
const CURATED_SINK_TYPES = [
'PreviewAny',
'DisplayAny',
'Display Any (rgthree)',
'ShowText|pysssss'
]
test.use({ initialSettings: customNodeSuiteSettings })
test.beforeEach(async ({ comfyPage }) => {
await dismissTemplatesDialog(comfyPage)
})
// Leave the shared backend idle so the next test starts clean (drainBackendToIdle).
test.afterEach(async ({ comfyPage }) => {
// The drain is a no-op when the queue is already idle, so it costs
// ~nothing in the common path; the 10s ceiling only bounds a genuinely
// busy backend. A backend still busy past it is wedged, and the auto-run
// tier's 150s guard surfaces that with the restart diagnostic.
await drainBackendToIdle(comfyPage.page, 10_000)
})
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')
// vueNodesCompatible: false = canvas-only assertions; still runs, no skip.
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()
// T0 loads and renders nodes but queues no prompt; a prompt-execution
// error here is a prior tier's async stray (isForeignExecutionNoise).
expect(
consoleErrors.errors.filter(
(error) => !isForeignExecutionNoise(error)
),
`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')
// Pack scripts can throw during workflow load or execution without
// any visible error surface; collect console + uncaught page errors
// across the whole run, filtered through the shared pack ledger.
const consoleErrors = collectConsoleErrors(comfyPage.page)
await comfyPage.workflow.loadGraphData(readWorkflow(workflowRelative))
// A drifted fixture that dropped an expected node would silently
// shrink the executed-set assertion (an empty id list PASSes on
// execution_success alone): require every expected type to actually
// be present in the loaded workflow before running it.
const expectedNodeIds: string[] = []
for (const type of entry.expectedNodes) {
const ids = await nodeIdsByType(comfyPage.page, [type])
expect(
ids.length,
`expectedNodes drift: ${type} is not in the curated workflow ${entry.workflow}`
).toBeGreaterThan(0)
expectedNodeIds.push(...ids)
}
const result = await target.runWorkflow(comfyPage.page, {
expectedNodeIds,
timeoutMs: entry.timeoutMs
})
// A run that executed and errored carries an ExecutionError; a run the
// backend rejected before executing (VALIDATION_FAIL) carries only the
// captured node_errors text in clientError - surface whichever exists so
// a red names the cause instead of printing an empty object.
expect(
result.outcome,
result.clientError ?? JSON.stringify(result.error ?? {})
).toBe('PASS')
// PASS proves execution completed; the sinks prove data ARRIVED.
// Every display sink in the curated workflow must have emitted a ui
// payload through its executed event.
const sinkIds = await nodeIdsByType(comfyPage.page, CURATED_SINK_TYPES)
for (const sinkId of sinkIds)
expect(
result.outputsByNode[sinkId],
`sink node ${sinkId} produced no ui payload`
).toBeTruthy()
await expectNoVisibleErrors(comfyPage.page, 'after run')
consoleErrors.stop()
expect(
unallowlistedErrors(entry.pack, consoleErrors.errors),
'console errors during curated run'
).toEqual([])
})
})
}
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()
})
test('collector self-check: captures uncaught page exceptions', async ({
comfyPage
}) => {
// Positive control for the console collector: an uncaught async throw
// never reaches console.error, so this proves the pageerror listener
// works. If this fails, every zero-console-errors assertion in the suite
// is blind to the whole uncaught-exception class.
const collected = collectConsoleErrors(comfyPage.page)
await comfyPage.page.evaluate(() => {
setTimeout(() => {
throw new Error('cn-collector-self-check')
}, 0)
})
await expect
.poll(() =>
collected.errors.some((error) =>
error.includes('cn-collector-self-check')
)
)
.toBe(true)
collected.stop()
})
test('attribution self-check: a foreign-prompt terminal event cannot fail this run', async ({
comfyPage
}) => {
test.setTimeout(30_000)
const objectInfo = await target.getObjectInfo(comfyPage.page)
test.skip(
!('PrimitiveInt' in objectInfo) || !('PreviewAny' in objectInfo),
'core Primitive/PreviewAny nodes unavailable on this backend'
)
await comfyPage.workflow.loadGraphData(
readWorkflow(assetPath('customNodes/core_primitive_preview_run.json'))
)
// Once the run's event tap starts filling, inject ONE terminal error under
// a prompt id this page never queued. The positive prompt-id filter must
// discard it; the pre-capture harness let the never-seen id through the
// seen-set and misclassified the run as EXECUTION_ERROR. This is the
// discriminating guard for the foreign-attribution bug class.
await comfyPage.page.evaluate(() => {
const timer = setInterval(() => {
const sink = (window as unknown as { __cnEvents?: object[] }).__cnEvents
if (!sink || sink.length === 0) return
sink.push({
type: 'execution_error',
prompt_id: 'cn-foreign-self-check',
exception_type: 'ForeignError',
node_id: '424242'
})
clearInterval(timer)
}, 25)
})
const result = await target.runWorkflow(comfyPage.page, {
expectedNodeIds: await nodeIdsByType(comfyPage.page, [
'PrimitiveInt',
'PreviewAny'
]),
timeoutMs: 15000
})
expect(result.outcome, JSON.stringify(result.error ?? {})).toBe('PASS')
expect(result.error).toBeUndefined()
})

View File

@@ -0,0 +1,76 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { CustomNodeManifestEntry } from '@e2e/fixtures/customNode/manifest'
import {
assertEntry,
loadManifest,
rendererPassesFor
} from '@e2e/fixtures/customNode/manifest'
function validEntry(): CustomNodeManifestEntry {
return {
pack: 'Example-Pack',
repo: 'https://github.com/example/Example-Pack',
pin: 'a1'.repeat(20),
tiers: ['load', 'connectivity', 'run'],
workflow: 'assets/customNodes/example_run.json',
expectedNodes: ['ExampleNode'],
requiresGpu: false,
requiresModels: [],
timeoutMs: 60_000
}
}
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])
})
test('pin must be a full commit SHA; only the canary override admits an empty one', () => {
// Deterministic regardless of ambient env (a canary environment sets
// the override): pin the var for the test, restore the prior value.
const prior = process.env.CUSTOM_NODES_ALLOW_UNPINNED
delete process.env.CUSTOM_NODES_ALLOW_UNPINNED
try {
expect(() => assertEntry(validEntry(), 0)).not.toThrow()
expect(() => assertEntry({ ...validEntry(), pin: '' }, 0)).toThrow(/pin/)
expect(() => assertEntry({ ...validEntry(), pin: 'abc123' }, 0)).toThrow(
/pin/
)
process.env.CUSTOM_NODES_ALLOW_UNPINNED = '1'
expect(() => assertEntry({ ...validEntry(), pin: '' }, 0)).not.toThrow()
// the override admits only EMPTY pins; a malformed pin still fails
expect(() => assertEntry({ ...validEntry(), pin: 'abc123' }, 0)).toThrow(
/pin/
)
} finally {
if (prior === undefined) delete process.env.CUSTOM_NODES_ALLOW_UNPINNED
else process.env.CUSTOM_NODES_ALLOW_UNPINNED = prior
}
})
test('pack must be a plain path segment (it becomes the install dirname)', () => {
for (const bad of ['../escape', 'a/b', '.hidden', 'sp ace', ''])
expect(
() => assertEntry({ ...validEntry(), pack: bad }, 0),
`pack '${bad}' must be rejected`
).toThrow(/pack/)
})
})

View File

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

View File

@@ -0,0 +1,51 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { summarizePromptError } from '@e2e/fixtures/customNode/ComfyTarget'
// The curated-run happy path never executes summarizePromptError (it only
// runs on a VALIDATION_FAIL), so these cases are what keep a T1 rejection
// naming the node+input instead of rotting back to `{}`.
test.describe('summarizePromptError', () => {
test('names the node class and the failing input from node_errors', () => {
const body = {
error: { type: 'prompt_outputs_failed_validation', message: 'failed' },
node_errors: {
'7': {
class_type: 'ImpactInt',
errors: [
{ type: 'value_not_in_list', message: 'msg', details: 'value' }
],
dependent_outputs: []
}
}
}
expect(summarizePromptError(body)).toBe('failed; ImpactInt: value')
})
test('accepts a string top-level error', () => {
expect(summarizePromptError({ error: 'bad request' })).toBe('bad request')
})
test('falls back to the node message when details is empty', () => {
const body = {
node_errors: {
'3': {
class_type: 'KSampler',
errors: [
{ type: 'x', message: 'required input missing', details: '' }
],
dependent_outputs: []
}
}
}
expect(summarizePromptError(body)).toBe('KSampler: required input missing')
})
test('returns undefined for an empty or non-object body', () => {
expect(summarizePromptError({})).toBeUndefined()
expect(summarizePromptError(null)).toBeUndefined()
expect(summarizePromptError('not an object')).toBeUndefined()
})
})

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

View File

@@ -0,0 +1,270 @@
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', comboOptions: ['a', 'b'] }
])
const socketless = nodes.find((n) => n.type === 'SocketlessNode')!
expect(socketless.inputs).toEqual([])
// socketless is a recognized shape deliberately left out of the matrix;
// it must never be recorded as an unknown slot.
expect(socketless.unknownSlots).toBeUndefined()
})
test('unrecognizable slot specs are recorded, never silently dropped', () => {
// A numeric input type and a numeric output type have no connectable
// socket type (slotTypeOf null): the slot leaves the corpus, but the
// drop must surface on the node and in the plan.
const nodes = normalizeNodeDefs({
WeirdNode: {
input: { required: { strange: [42, {}], ok: ['INT', {}] } },
output: [7, 'INT'],
python_module: 'custom_nodes.weird-pack'
}
})
const weird = nodes.find((n) => n.type === 'WeirdNode')!
expect(weird.unknownSlots).toEqual(['strange', 'output[0]'])
expect(weird.inputs.map((s) => s.name)).toEqual(['ok'])
const plan = planPairs(nodes, ['WeirdNode'])
expect(plan.unknownShapes).toEqual([
'WeirdNode.strange',
'WeirdNode.output[0]'
])
})
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)
// The DEFS corpus is fully recognizable; unknownShapes stays empty.
expect(plan.unknownShapes).toEqual([])
})
test('COMBO slots with different vocabularies stay excluded', () => {
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', comboOptions: ['A', 'B', 'C'] }
])
// ComboNode.choice offers [a, b] - not the same vocabulary as [A, B, C].
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('COMBO slots with an identical vocabulary pair up', () => {
const nodes = normalizeNodeDefs({
SamplerNameSource: {
input: { required: {} },
output: [['euler', 'ddim']],
output_name: [['euler', 'ddim'] as unknown as string],
python_module: 'nodes'
},
SamplerNameSink: {
input: { required: { sampler_name: [['euler', 'ddim'], {}] } },
output: [],
python_module: 'nodes'
},
...DEFS
})
const plan = planPairs(nodes, ['SamplerNameSource', 'SamplerNameSink'])
expect(
plan.pairs.map(
(p) =>
`${p.producer.nodeType}.${p.producer.slotName}->${p.consumer.nodeType}.${p.consumer.slotName}`
)
).toEqual(['SamplerNameSource.COMBO->SamplerNameSink.sampler_name'])
expect(plan.combos).toEqual([])
})
// Census-derived: transformed (V2-schema) defs carry combo inputs as the
// string 'COMBO' with options in the opts object. Same vocabulary must
// pair across forms, and a combo with no static options (remote/lazy)
// must never blind-match.
test('V2-form combos pair across forms by vocabulary; unknown options never pair', () => {
const nodes = normalizeNodeDefs({
ListFormSource: {
input: { required: {} },
output: [['x', 'y']],
output_name: [['x', 'y'] as unknown as string],
python_module: 'nodes'
},
V2FormSink: {
input: {
required: {
dim: ['COMBO', { multiselect: false, options: ['y', 'x'] }]
}
},
output: [],
python_module: 'nodes'
},
RemoteComboSink: {
input: {
required: {
image: ['COMBO', { remote: { route: '/internal/files/output' } }]
}
},
output: [],
python_module: 'nodes'
},
...DEFS
})
const plan = planPairs(nodes, [
'ListFormSource',
'V2FormSink',
'RemoteComboSink'
])
expect(
plan.pairs.map(
(p) =>
`${p.producer.nodeType}.${p.producer.slotName}->${p.consumer.nodeType}.${p.consumer.slotName}`
)
).toEqual(['ListFormSource.COMBO->V2FormSink.dim'])
expect(plan.combos.map((s) => `${s.nodeType}.${s.slotName}`)).toEqual([
'RemoteComboSink.image'
])
})
test('COMBO vocabulary matching ignores option order', () => {
// A wired input bypasses its own widget, so menu order and the
// options[0] default are not part of the wire contract - membership is.
const nodes = normalizeNodeDefs({
ShuffledSource: {
input: { required: {} },
output: [['ddim', 'euler']],
output_name: [['ddim', 'euler'] as unknown as string],
python_module: 'nodes'
},
SamplerNameSink: {
input: { required: { sampler_name: [['euler', 'ddim'], {}] } },
output: [],
python_module: 'nodes'
},
...DEFS
})
const plan = planPairs(nodes, ['ShuffledSource', 'SamplerNameSink'])
expect(
plan.pairs.map(
(p) =>
`${p.producer.nodeType}.${p.producer.slotName}->${p.consumer.nodeType}.${p.consumer.slotName}`
)
).toEqual(['ShuffledSource.COMBO->SamplerNameSink.sampler_name'])
expect(plan.combos).toEqual([])
})
test('wildcard slots are excluded, orphan types recorded not failed', () => {
const nodes = normalizeNodeDefs(DEFS)
const plan = planPairs(nodes, ['WildcardNode', 'OrphanNode'])
expect(plan.wildcards.map((w) => w.nodeType)).toEqual([
'WildcardNode',
'WildcardNode'
])
expect(plan.orphans).toEqual([
{
nodeType: 'OrphanNode',
pack: 'OrphanPack',
slotName: 'orphan',
slotType: 'NOBODY_CONSUMES_THIS',
dir: 'out'
}
])
expect(plan.pairs).toEqual([])
})
})

View File

@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -25,8 +25,6 @@ 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 ({
@@ -56,7 +54,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -101,7 +99,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -143,7 +141,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -184,7 +182,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -222,7 +220,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()

View File

@@ -52,6 +52,16 @@
"test:browser": "pnpm exec playwright test",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:custom-nodes": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1",
"test:custom-nodes:ci": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm exec playwright test browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1",
"test:custom-nodes:watch": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 PLAYWRIGHT_LOCAL=1 SLOW_MO=300 pnpm exec playwright test browser_tests/tests/customNodes/customNode.regression.spec.ts browser_tests/tests/customNodes/connectivity.spec.ts --config playwright.chrome.config.ts --workers=1 --headed",
"test:custom-nodes:debug": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/customNodes/customNode.regression.spec.ts browser_tests/tests/customNodes/connectivity.spec.ts --config playwright.chrome.config.ts --workers=1 --debug",
"test:custom-nodes:impact-render": "pnpm test:custom-nodes:debug -g \"ComfyUI-Impact-Pack.*T0\"",
"test:custom-nodes:impact-run": "pnpm test:custom-nodes:debug -g \"ComfyUI-Impact-Pack.*T1\"",
"test:custom-nodes:vhs-render": "pnpm test:custom-nodes:debug -g \"VideoHelperSuite.*T0\"",
"test:custom-nodes:vhs-run": "pnpm test:custom-nodes:debug -g \"VideoHelperSuite.*T1\"",
"test:custom-nodes:connectivity": "pnpm test:custom-nodes:debug -g \"connectivity\"",
"test:custom-nodes:self-check": "pnpm test:custom-nodes:watch -g \"self-check\"",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",

View File

@@ -1,10 +1,4 @@
import {
cleanupSVG,
importDirectorySync,
isEmptyColor,
parseColors,
runSVGO
} from '@iconify/tools'
import { cleanupSVG, importDirectorySync, runSVGO } from '@iconify/tools'
import { resolve } from 'node:path'
export const COMFY_ICON_PREFIX = 'comfy'
@@ -13,13 +7,6 @@ const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
let cached
/**
* Load the comfy icon folder as a normalized Iconify icon set.
*
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
* runs internally so monotone hardcoded colors become `currentColor` and
* outer-svg attributes like `fill="none"` survive the body extraction.
*/
export function loadComfyIconSet() {
if (cached) return cached
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
@@ -32,18 +19,6 @@ export function loadComfyIconSet() {
}
try {
cleanupSVG(svg)
const palette = parseColors(svg)
const colors = palette.colors.filter(
(color) => typeof color === 'string' || !isEmptyColor(color)
)
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
if (totalColors < 2) {
parseColors(svg, {
defaultColor: 'currentColor',
callback: (_attr, colorStr, color) =>
!color || isEmptyColor(color) ? colorStr : 'currentColor'
})
}
runSVGO(svg)
iconSet.fromSVG(name, svg)
} catch {

View File

@@ -1,23 +0,0 @@
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
import plugin from 'tailwindcss/plugin'
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
const SCALE = 1.2
const options = {
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
scale: SCALE
}
export default plugin(({ matchComponents }) => {
matchComponents({
icon: (icon) => {
try {
return getDynamicCSSRules(icon, options)
} catch {
return {}
}
}
})
})

View File

@@ -0,0 +1,49 @@
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
import { matchIconName } from '@iconify/utils/lib/icon/name'
import plugin from 'tailwindcss/plugin'
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
const SCALE = 1.2
const options = {
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
scale: SCALE
}
function getModeCSSRules(icon: string, mode: 'mask' | 'background') {
const nameParts = icon.split(/--|:/)
if (nameParts.length !== 2) return {}
const [prefix, name] = nameParts
if (!(prefix.match(matchIconName) && name.match(matchIconName))) return {}
const iconSet =
prefix === COMFY_ICON_PREFIX ? loadComfyIconSet() : loadIconSet(prefix)
if (!iconSet) return {}
const generated = getIconsCSSData(iconSet, [name], {
iconSelector: '.icon',
mode
})
if (generated.css.length !== 1) return {}
const size = { width: `${SCALE}em`, height: `${SCALE}em` }
return { ...generated.common?.rules, ...size, ...generated.css[0].rules }
}
export default plugin(({ matchComponents }) => {
matchComponents({
icon: (icon) => {
try {
return getDynamicCSSRules(icon, options)
} catch {
return {}
}
},
'icon-img': (icon) => getModeCSSRules(icon, 'background'),
'icon-mask': (icon) => getModeCSSRules(icon, 'mask')
})
})

View File

@@ -8,12 +8,12 @@
@plugin 'tailwindcss-primeui';
@plugin "./iconifyDynamicPlugin.js";
@plugin "./iconifyDynamicPlugin.ts";
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,claude,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,claude,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@custom-variant touch (@media (hover: none));

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 1021 B

View File

@@ -164,6 +164,34 @@ packages/design-system/src/icons/
No imports needed - icons are auto-discovered!
## Render Mode: Mask vs Image
The default render mode is decided by the SVG's own colors:
- **Mask** — SVG with `currentColor`. The shape is displayed as a background
mask, so it adapts to the theme via `text-*` classes.
- **Image** — the SVG uses concrete colors (e.g. `#d97757`). It is embedded
as an image, preserving its own colors; `text-*` has no effect. Use this for
brand logos that must keep their palette.
```
workflow.svg fill="currentColor" -> mask (themeable)
claude.svg fill="#d97757" -> image (brand color preserved)
```
### Forcing a mode at usage time
```vue
<template>
<i class="icon-[comfy--openai]" />
<!-- default from SVG colors -->
<i class="icon-img-[comfy--openai]" />
<!-- force image, own colors -->
<i class="icon-mask-[comfy--luma]" />
<!-- force themeable mask -->
</template>
```
## Icon Guidelines
### Naming Conventions

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M20 15C20.5523 15 21 14.5523 21 14V5C21 4.46957 20.7893 3.96086 20.4142 3.58579C20.0391 3.21071 19.5304 3 19 3H10C9.44772 3 9 3.44772 9 4M17 18C17.5523 18 18 17.5523 18 17V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H7C6.44772 6 6 6.44772 6 7M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M20 15C20.5523 15 21 14.5523 21 14V5C21 4.46957 20.7893 3.96086 20.4142 3.58579C20.0391 3.21071 19.5304 3 19 3H10C9.44772 3 9 3.44772 9 4M17 18C17.5523 18 18 17.5523 18 17V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H7C6.44772 6 6 6.44772 6 7M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 937 B

After

Width:  |  Height:  |  Size: 942 B

View File

@@ -1,6 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1471_12672)">
<path d="M365.047 229.802H310.584L256.118 153.072L86.2157 392.168H140.796L256.115 229.807H310.581L195.261 392.168H249.994L365.047 229.802L512 436.698H470.893V436.7H426.019V392.343L365.047 306.532L304.415 392.178V436.698H163.632L163.629 436.703H109.164L109.167 436.698H-0.105347L256.118 76L365.047 229.802Z" fill="white"/>
<path d="M365.047 229.802H310.584L256.118 153.072L86.2157 392.168H140.796L256.115 229.807H310.581L195.261 392.168H249.994L365.047 229.802L512 436.698H470.893V436.7H426.019V392.343L365.047 306.532L304.415 392.178V436.698H163.632L163.629 436.703H109.164L109.167 436.698H-0.105347L256.118 76L365.047 229.802Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_1471_12672">

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 586 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.65 6.36397V5.71397H11.35V6.36397H12H12.65ZM11.35 17.6777C11.35 18.0367 11.641 18.3277 12 18.3277C12.359 18.3277 12.65 18.0367 12.65 17.6777H12H11.35ZM17.6569 14.0049L18.2695 14.222L17.6569 14.0049ZM17.6586 10L18.2714 9.78339L17.6586 10ZM16.6114 15.8388L17.1107 16.255L16.6114 15.8388ZM12.65 20.5C12.65 20.141 12.359 19.85 12 19.85C11.641 19.85 11.35 20.141 11.35 20.5H12H12.65ZM11.35 22C11.35 22.359 11.641 22.65 12 22.65C12.359 22.65 12.65 22.359 12.65 22H12H11.35ZM12.65 2C12.65 1.64101 12.359 1.35 12 1.35C11.641 1.35 11.35 1.64101 11.35 2H12H12.65ZM11.35 3.5C11.35 3.85899 11.641 4.15 12 4.15C12.359 4.15 12.65 3.85899 12.65 3.5H12H11.35ZM20.5 11.35C20.141 11.35 19.85 11.641 19.85 12C19.85 12.359 20.141 12.65 20.5 12.65V12V11.35ZM22 12.65C22.359 12.65 22.65 12.359 22.65 12C22.65 11.641 22.359 11.35 22 11.35V12V12.65ZM2 11.35C1.64101 11.35 1.35 11.641 1.35 12C1.35 12.359 1.64101 12.65 2 12.65V12V11.35ZM3.5 12.65C3.85899 12.65 4.15 12.359 4.15 12C4.15 11.641 3.85899 11.35 3.5 11.35V12V12.65ZM18.47 17.5508C18.2162 17.2969 17.8046 17.2969 17.5508 17.5508C17.2969 17.8046 17.2969 18.2162 17.5508 18.47L18.0104 18.0104L18.47 17.5508ZM18.6114 19.5307C18.8653 19.7845 19.2768 19.7845 19.5307 19.5307C19.7845 19.2768 19.7845 18.8653 19.5307 18.6114L19.0711 19.0711L18.6114 19.5307ZM5.38855 4.46931C5.13471 4.21547 4.72315 4.21547 4.46931 4.46931C4.21547 4.72315 4.21547 5.13471 4.46931 5.38855L4.92893 4.92893L5.38855 4.46931ZM5.52997 6.44921C5.78381 6.70305 6.19537 6.70305 6.44921 6.44921C6.70305 6.19537 6.70305 5.78381 6.44921 5.52997L5.98959 5.98959L5.52997 6.44921ZM6.44921 18.47C6.70305 18.2162 6.70305 17.8046 6.44921 17.5508C6.19537 17.2969 5.78382 17.2969 5.52997 17.5508L5.98959 18.0104L6.44921 18.47ZM4.46931 18.6114C4.21547 18.8653 4.21547 19.2768 4.46931 19.5307C4.72315 19.7845 5.13471 19.7845 5.38855 19.5307L4.92893 19.0711L4.46931 18.6114ZM19.5307 5.38855C19.7845 5.13471 19.7845 4.72315 19.5307 4.46931C19.2768 4.21547 18.8653 4.21547 18.6114 4.46931L19.0711 4.92893L19.5307 5.38855ZM17.5508 5.52997C17.2969 5.78381 17.2969 6.19537 17.5508 6.44921C17.8046 6.70305 18.2162 6.70305 18.47 6.44921L18.0104 5.98959L17.5508 5.52997ZM12 18V17.35C9.04528 17.35 6.65 14.9547 6.65 12H6H5.35C5.35 15.6727 8.32731 18.65 12 18.65V18ZM6 12H6.65C6.65 9.04528 9.04528 6.65 12 6.65V6V5.35C8.32731 5.35 5.35 8.32731 5.35 12H6ZM18 12H17.35C17.35 12.6281 17.242 13.2296 17.0442 13.7878L17.6569 14.0049L18.2695 14.222C18.5161 13.5264 18.65 12.7781 18.65 12H18ZM12 14L11.9994 14.65L17.6563 14.6549L17.6569 14.0049L17.6574 13.3549L12.0006 13.35L12 14ZM12 12H11.35V14H12H12.65V12H12ZM12 12V12.65H18V12V11.35H12V12ZM12 10H11.35V12H12H12.65V10H12ZM17.6586 10L17.0457 10.2166C17.2426 10.7736 17.35 11.3735 17.35 12H18H18.65C18.65 11.2239 18.5168 10.4776 18.2714 9.78339L17.6586 10ZM12 10V10.65H17.6586V10V9.35H12V10ZM12 14H11.35V15.8388H12H12.65V14H12ZM12 15.8388H11.35V17.6777H12H12.65V15.8388H12ZM17.6569 14.0049L17.0442 13.7878C16.8311 14.3891 16.5132 14.9414 16.1121 15.4227L16.6114 15.8388L17.1107 16.255C17.6087 15.6575 18.0041 14.9708 18.2695 14.222L17.6569 14.0049ZM16.6114 15.8388L16.1121 15.4227C15.1297 16.6015 13.6525 17.35 12 17.35V18V18.65C14.0546 18.65 15.8919 17.7175 17.1107 16.255L16.6114 15.8388ZM12 15.8388V16.4888H16.6114V15.8388V15.1888H12V15.8388ZM12 6.36397H11.35V8.18199H12H12.65V6.36397H12ZM12 8.18199H11.35V10H12H12.65V8.18199H12ZM12 6V6.65C13.6612 6.65 15.1452 7.40634 16.1275 8.59587L16.6287 8.18199L17.1299 7.7681C15.9112 6.29234 14.0654 5.35 12 5.35V6ZM16.6287 8.18199L16.1275 8.59587C16.5223 9.07398 16.8353 9.62135 17.0457 10.2166L17.6586 10L18.2714 9.78339C18.0094 9.0421 17.62 8.36161 17.1299 7.7681L16.6287 8.18199ZM12 8.18199V8.83199H16.6287V8.18199V7.53199H12V8.18199ZM12 20.5H11.35V22H12H12.65V20.5H12ZM12 2H11.35V3.5H12H12.65V2H12ZM20.5 12V12.65H22V12V11.35H20.5V12ZM2 12V12.65H3.5V12V11.35H2V12ZM18.0104 18.0104L17.5508 18.47L18.6114 19.5307L19.0711 19.0711L19.5307 18.6114L18.47 17.5508L18.0104 18.0104ZM4.92893 4.92893L4.46931 5.38855L5.52997 6.44921L5.98959 5.98959L6.44921 5.52997L5.38855 4.46931L4.92893 4.92893ZM5.98959 18.0104L5.52997 17.5508L4.46931 18.6114L4.92893 19.0711L5.38855 19.5307L6.44921 18.47L5.98959 18.0104ZM19.0711 4.92893L18.6114 4.46931L17.5508 5.52997L18.0104 5.98959L18.47 6.44921L19.5307 5.38855L19.0711 4.92893Z" fill="white"/>
<path d="M12.65 6.36397V5.71397H11.35V6.36397H12H12.65ZM11.35 17.6777C11.35 18.0367 11.641 18.3277 12 18.3277C12.359 18.3277 12.65 18.0367 12.65 17.6777H12H11.35ZM17.6569 14.0049L18.2695 14.222L17.6569 14.0049ZM17.6586 10L18.2714 9.78339L17.6586 10ZM16.6114 15.8388L17.1107 16.255L16.6114 15.8388ZM12.65 20.5C12.65 20.141 12.359 19.85 12 19.85C11.641 19.85 11.35 20.141 11.35 20.5H12H12.65ZM11.35 22C11.35 22.359 11.641 22.65 12 22.65C12.359 22.65 12.65 22.359 12.65 22H12H11.35ZM12.65 2C12.65 1.64101 12.359 1.35 12 1.35C11.641 1.35 11.35 1.64101 11.35 2H12H12.65ZM11.35 3.5C11.35 3.85899 11.641 4.15 12 4.15C12.359 4.15 12.65 3.85899 12.65 3.5H12H11.35ZM20.5 11.35C20.141 11.35 19.85 11.641 19.85 12C19.85 12.359 20.141 12.65 20.5 12.65V12V11.35ZM22 12.65C22.359 12.65 22.65 12.359 22.65 12C22.65 11.641 22.359 11.35 22 11.35V12V12.65ZM2 11.35C1.64101 11.35 1.35 11.641 1.35 12C1.35 12.359 1.64101 12.65 2 12.65V12V11.35ZM3.5 12.65C3.85899 12.65 4.15 12.359 4.15 12C4.15 11.641 3.85899 11.35 3.5 11.35V12V12.65ZM18.47 17.5508C18.2162 17.2969 17.8046 17.2969 17.5508 17.5508C17.2969 17.8046 17.2969 18.2162 17.5508 18.47L18.0104 18.0104L18.47 17.5508ZM18.6114 19.5307C18.8653 19.7845 19.2768 19.7845 19.5307 19.5307C19.7845 19.2768 19.7845 18.8653 19.5307 18.6114L19.0711 19.0711L18.6114 19.5307ZM5.38855 4.46931C5.13471 4.21547 4.72315 4.21547 4.46931 4.46931C4.21547 4.72315 4.21547 5.13471 4.46931 5.38855L4.92893 4.92893L5.38855 4.46931ZM5.52997 6.44921C5.78381 6.70305 6.19537 6.70305 6.44921 6.44921C6.70305 6.19537 6.70305 5.78381 6.44921 5.52997L5.98959 5.98959L5.52997 6.44921ZM6.44921 18.47C6.70305 18.2162 6.70305 17.8046 6.44921 17.5508C6.19537 17.2969 5.78382 17.2969 5.52997 17.5508L5.98959 18.0104L6.44921 18.47ZM4.46931 18.6114C4.21547 18.8653 4.21547 19.2768 4.46931 19.5307C4.72315 19.7845 5.13471 19.7845 5.38855 19.5307L4.92893 19.0711L4.46931 18.6114ZM19.5307 5.38855C19.7845 5.13471 19.7845 4.72315 19.5307 4.46931C19.2768 4.21547 18.8653 4.21547 18.6114 4.46931L19.0711 4.92893L19.5307 5.38855ZM17.5508 5.52997C17.2969 5.78381 17.2969 6.19537 17.5508 6.44921C17.8046 6.70305 18.2162 6.70305 18.47 6.44921L18.0104 5.98959L17.5508 5.52997ZM12 18V17.35C9.04528 17.35 6.65 14.9547 6.65 12H6H5.35C5.35 15.6727 8.32731 18.65 12 18.65V18ZM6 12H6.65C6.65 9.04528 9.04528 6.65 12 6.65V6V5.35C8.32731 5.35 5.35 8.32731 5.35 12H6ZM18 12H17.35C17.35 12.6281 17.242 13.2296 17.0442 13.7878L17.6569 14.0049L18.2695 14.222C18.5161 13.5264 18.65 12.7781 18.65 12H18ZM12 14L11.9994 14.65L17.6563 14.6549L17.6569 14.0049L17.6574 13.3549L12.0006 13.35L12 14ZM12 12H11.35V14H12H12.65V12H12ZM12 12V12.65H18V12V11.35H12V12ZM12 10H11.35V12H12H12.65V10H12ZM17.6586 10L17.0457 10.2166C17.2426 10.7736 17.35 11.3735 17.35 12H18H18.65C18.65 11.2239 18.5168 10.4776 18.2714 9.78339L17.6586 10ZM12 10V10.65H17.6586V10V9.35H12V10ZM12 14H11.35V15.8388H12H12.65V14H12ZM12 15.8388H11.35V17.6777H12H12.65V15.8388H12ZM17.6569 14.0049L17.0442 13.7878C16.8311 14.3891 16.5132 14.9414 16.1121 15.4227L16.6114 15.8388L17.1107 16.255C17.6087 15.6575 18.0041 14.9708 18.2695 14.222L17.6569 14.0049ZM16.6114 15.8388L16.1121 15.4227C15.1297 16.6015 13.6525 17.35 12 17.35V18V18.65C14.0546 18.65 15.8919 17.7175 17.1107 16.255L16.6114 15.8388ZM12 15.8388V16.4888H16.6114V15.8388V15.1888H12V15.8388ZM12 6.36397H11.35V8.18199H12H12.65V6.36397H12ZM12 8.18199H11.35V10H12H12.65V8.18199H12ZM12 6V6.65C13.6612 6.65 15.1452 7.40634 16.1275 8.59587L16.6287 8.18199L17.1299 7.7681C15.9112 6.29234 14.0654 5.35 12 5.35V6ZM16.6287 8.18199L16.1275 8.59587C16.5223 9.07398 16.8353 9.62135 17.0457 10.2166L17.6586 10L18.2714 9.78339C18.0094 9.0421 17.62 8.36161 17.1299 7.7681L16.6287 8.18199ZM12 8.18199V8.83199H16.6287V8.18199V7.53199H12V8.18199ZM12 20.5H11.35V22H12H12.65V20.5H12ZM12 2H11.35V3.5H12H12.65V2H12ZM20.5 12V12.65H22V12V11.35H20.5V12ZM2 12V12.65H3.5V12V11.35H2V12ZM18.0104 18.0104L17.5508 18.47L18.6114 19.5307L19.0711 19.0711L19.5307 18.6114L18.47 17.5508L18.0104 18.0104ZM4.92893 4.92893L4.46931 5.38855L5.52997 6.44921L5.98959 5.98959L6.44921 5.52997L5.38855 4.46931L4.92893 4.92893ZM5.98959 18.0104L5.52997 17.5508L4.46931 18.6114L4.92893 19.0711L5.38855 19.5307L6.44921 18.47L5.98959 18.0104ZM19.0711 4.92893L18.6114 4.46931L17.5508 5.52997L18.0104 5.98959L18.47 6.44921L19.5307 5.38855L19.0711 4.92893Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,6 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2508 12.7276C12.4587 13.0908 11.5409 13.0908 10.7488 12.7276M10.7488 7.2724C11.5409 6.9092 12.4587 6.9092 13.2508 7.2724M9.27202 11.251C8.90883 10.4589 8.90883 9.54111 9.27202 8.74897M14.7272 8.74897C15.0904 9.54111 15.0904 10.4589 14.7272 11.251M6.18003 19.5412C6.06141 20.0143 6 20.504 6 21M18 21C18 20.504 17.9386 20.0143 17.82 19.5412M7 17.6833C7.21936 17.3526 7.47257 17.0421 7.75736 16.7574C8.04215 16.4726 8.35262 16.2194 8.68333 16M17 17.6833C16.7806 17.3526 16.5274 17.0421 16.2426 16.7574C15.9579 16.4726 15.6474 16.2194 15.3167 16M10.6747 15.1482C11.1062 15.0504 11.5505 15 12 15C12.4495 15 12.8938 15.0504 13.3253 15.1482M5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.2508 12.7276C12.4587 13.0908 11.5409 13.0908 10.7488 12.7276M10.7488 7.2724C11.5409 6.9092 12.4587 6.9092 13.2508 7.2724M9.27202 11.251C8.90883 10.4589 8.90883 9.54111 9.27202 8.74897M14.7272 8.74897C15.0904 9.54111 15.0904 10.4589 14.7272 11.251M6.18003 19.5412C6.06141 20.0143 6 20.504 6 21M18 21C18 20.504 17.9386 20.0143 17.82 19.5412M7 17.6833C7.21936 17.3526 7.47257 17.0421 7.75736 16.7574C8.04215 16.4726 8.35262 16.2194 8.68333 16M17 17.6833C16.7806 17.3526 16.5274 17.0421 16.2426 16.7574C15.9579 16.4726 15.6474 16.2194 15.3167 16M10.6747 15.1482C11.1062 15.0504 11.5505 15 12 15C12.4495 15 12.8938 15.0504 13.3253 15.1482M5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 960 B

View File

@@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 9C18 12.3137 15.3137 15 12 15C8.68629 15 6 12.3137 6 9C6 5.68629 8.68629 3 12 3C15.3137 3 18 5.68629 18 9Z" stroke="white" stroke-width="1.3"/>
<path d="M14.5 15C14.5 18.3137 11.8137 21 8.5 21C5.18629 21 2.5 18.3137 2.5 15C2.5 11.6863 5.18629 9 8.5 9C11.8137 9 14.5 11.6863 14.5 15Z" stroke="white" stroke-width="1.3"/>
<path d="M21.5 15C21.5 18.3137 18.8137 21 15.5 21C12.1863 21 9.5 18.3137 9.5 15C9.5 11.6863 12.1863 9 15.5 9C18.8137 9 21.5 11.6863 21.5 15Z" stroke="white" stroke-width="1.3"/>
<path d="M18 9C18 12.3137 15.3137 15 12 15C8.68629 15 6 12.3137 6 9C6 5.68629 8.68629 3 12 3C15.3137 3 18 5.68629 18 9Z" stroke="currentColor" stroke-width="1.3"/>
<path d="M14.5 15C14.5 18.3137 11.8137 21 8.5 21C5.18629 21 2.5 18.3137 2.5 15C2.5 11.6863 5.18629 9 8.5 9C11.8137 9 14.5 11.6863 14.5 15Z" stroke="currentColor" stroke-width="1.3"/>
<path d="M21.5 15C21.5 18.3137 18.8137 21 15.5 21C12.1863 21 9.5 18.3137 9.5 15C9.5 11.6863 12.1863 9 15.5 9C18.8137 9 21.5 11.6863 21.5 15Z" stroke="currentColor" stroke-width="1.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 614 B

After

Width:  |  Height:  |  Size: 635 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6154 18.8127C10.0968 18.9352 9.55598 19 9 19C5.13401 19 2 15.866 2 12C2 8.13401 5.13401 5 9 5C9.55598 5 10.0968 5.06482 10.6154 5.18731M13.3846 18.8127C13.9032 18.9352 14.444 19 15 19C18.866 19 22 15.866 22 12C22 8.13401 18.866 5 15 5C14.444 5 13.9032 5.06482 13.3846 5.18731M19 12C19 15.866 15.866 19 12 19C8.13401 19 5 15.866 5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12Z" stroke="white" stroke-width="1.3"/>
<path d="M10.6154 18.8127C10.0968 18.9352 9.55598 19 9 19C5.13401 19 2 15.866 2 12C2 8.13401 5.13401 5 9 5C9.55598 5 10.0968 5.06482 10.6154 5.18731M13.3846 18.8127C13.9032 18.9352 14.444 19 15 19C18.866 19 22 15.866 22 12C22 8.13401 18.866 5 15 5C14.444 5 13.9032 5.06482 13.3846 5.18731M19 12C19 15.866 15.866 19 12 19C8.13401 19 5 15.866 5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12Z" stroke="currentColor" stroke-width="1.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 545 B

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M11.3461 4.65384C11.1524 4.46662 10.9061 4.30769 10.6154 4.30769H3.07692C2.77116 4.30769 2.49515 4.43512 2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384ZM11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473C13.4891 2.12025 13.2201 2 12.9231 2H5.38463C5.07191 2 4.79033 2.13329 4.59359 2.34615L2.29912 4.63977M11.3461 4.65384L13.6839 2.31473M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M11.3461 4.65384C11.1524 4.46662 10.9061 4.30769 10.6154 4.30769H3.07692C2.77116 4.30769 2.49515 4.43512 2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384ZM11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473C13.4891 2.12025 13.2201 2 12.9231 2H5.38463C5.07191 2 4.79033 2.13329 4.59359 2.34615L2.29912 4.63977M11.3461 4.65384L13.6839 2.31473M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M11.3461 4.65384C11.1524 4.46662 10.9061 4.30769 10.6154 4.30769H3.07692C2.77116 4.30769 2.49515 4.43512 2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384ZM11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473C13.4891 2.12025 13.2201 2 12.9231 2H5.38463C5.07191 2 4.79033 2.13329 4.59359 2.34615L2.29912 4.63977M11.3461 4.65384L13.6839 2.31473M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M11.3461 4.65384C11.1524 4.46662 10.9061 4.30769 10.6154 4.30769H3.07692C2.77116 4.30769 2.49515 4.43512 2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384ZM11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473C13.4891 2.12025 13.2201 2 12.9231 2H5.38463C5.07191 2 4.79033 2.13329 4.59359 2.34615L2.29912 4.63977M11.3461 4.65384L13.6839 2.31473M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.8 12H20M4 12H5.2M16.8083 16.8083L17.6569 17.6569M6.34315 6.34315L7.19167 7.19167M7.1907 16.8083L6.34217 17.6569M17.6559 6.34315L16.8074 7.19167M12 18.8V20M12 4V5.2M11.4448 10.6596L9.7218 6.49994M16.2426 7.75736C18.5858 10.1005 18.5858 13.8995 16.2426 16.2426C13.8995 18.5858 10.1005 18.5858 7.75736 16.2426C5.41421 13.8995 5.41421 10.1005 7.75736 7.75736C10.1005 5.41421 13.8995 5.41421 16.2426 7.75736ZM18.0104 5.98959C21.3299 9.30905 21.3299 14.691 18.0104 18.0104C14.691 21.3299 9.30905 21.3299 5.98959 18.0104C2.67014 14.691 2.67014 9.30905 5.98959 5.98959C9.30905 2.67014 14.691 2.67014 18.0104 5.98959Z" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
<path d="M18.8 12H20M4 12H5.2M16.8083 16.8083L17.6569 17.6569M6.34315 6.34315L7.19167 7.19167M7.1907 16.8083L6.34217 17.6569M17.6559 6.34315L16.8074 7.19167M12 18.8V20M12 4V5.2M11.4448 10.6596L9.7218 6.49994M16.2426 7.75736C18.5858 10.1005 18.5858 13.8995 16.2426 16.2426C13.8995 18.5858 10.1005 18.5858 7.75736 16.2426C5.41421 13.8995 5.41421 10.1005 7.75736 7.75736C10.1005 5.41421 13.8995 5.41421 16.2426 7.75736ZM18.0104 5.98959C21.3299 9.30905 21.3299 14.691 18.0104 18.0104C14.691 21.3299 9.30905 21.3299 5.98959 18.0104C2.67014 14.691 2.67014 9.30905 5.98959 5.98959C9.30905 2.67014 14.691 2.67014 18.0104 5.98959Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 785 B

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M2 11.3333H8M5 14V11.3333M8 14L8 11.3333M8 2H13C13.5523 2 14 2.44772 14 3L14 4.66667V11.3333L14 13C14 13.5523 13.5523 14 13 14H8L3 14C2.44771 14 2 13.5523 2 13V3C2 2.44771 2.44772 2 3 2L8 2ZM8 14V2M8 2L8 4.66667M2 4.66667H8M5 4.66667V2M8 4.66667V11.3333M8 4.66667H14M8 11.3333H14M11 14V11.3333M11 4.66667V2" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M2 11.3333H8M5 14V11.3333M8 14L8 11.3333M8 2H13C13.5523 2 14 2.44772 14 3L14 4.66667V11.3333L14 13C14 13.5523 13.5523 14 13 14H8L3 14C2.44771 14 2 13.5523 2 13V3C2 2.44771 2.44772 2 3 2L8 2ZM8 14V2M8 2L8 4.66667M2 4.66667H8M5 4.66667V2M8 4.66667V11.3333M8 4.66667H14M8 11.3333H14M11 14V11.3333M11 4.66667V2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 771 B

View File

@@ -1,6 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -1,3 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.0104 18.0104C21.3299 14.691 21.3299 9.30905 18.0104 5.98959C14.691 2.67014 9.30905 2.67014 5.98959 5.98959C2.67014 9.30905 2.67014 14.691 5.98959 18.0104C9.30905 21.3299 14.691 21.3299 18.0104 18.0104Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-dasharray="1 2"/>
<path d="M20.5 12H22M2 12H3.5M18.0103 18.0103L19.778 19.778M4.22168 4.22168L5.98945 5.98945M5.98974 18.0103L4.22197 19.778M19.7783 4.22168L18.0106 5.98945M12 20.5V22M12 2V3.5M16.2426 7.75736C18.5858 10.1005 18.5858 13.8995 16.2426 16.2426C13.8995 18.5858 10.1005 18.5858 7.75736 16.2426C5.41421 13.8995 5.41421 10.1005 7.75736 7.75736C10.1005 5.41421 13.8995 5.41421 16.2426 7.75736Z" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
<path d="M18.0104 18.0104C21.3299 14.691 21.3299 9.30905 18.0104 5.98959C14.691 2.67014 9.30905 2.67014 5.98959 5.98959C2.67014 9.30905 2.67014 14.691 5.98959 18.0104C9.30905 21.3299 14.691 21.3299 18.0104 18.0104Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-dasharray="1 2"/>
<path d="M20.5 12H22M2 12H3.5M18.0103 18.0103L19.778 19.778M4.22168 4.22168L5.98945 5.98945M5.98974 18.0103L4.22197 19.778M19.7783 4.22168L18.0106 5.98945M12 20.5V22M12 2V3.5M16.2426 7.75736C18.5858 10.1005 18.5858 13.8995 16.2426 16.2426C13.8995 18.5858 10.1005 18.5858 7.75736 16.2426C5.41421 13.8995 5.41421 10.1005 7.75736 7.75736C10.1005 5.41421 13.8995 5.41421 16.2426 7.75736Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 845 B

After

Width:  |  Height:  |  Size: 859 B

View File

@@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3H5C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6504 18.3252C13.8297 18.3254 13.9746 18.471 13.9746 18.6504C13.9744 18.8296 13.8296 18.9744 13.6504 18.9746C13.471 18.9746 13.3254 18.8297 13.3252 18.6504C13.3252 18.4709 13.4709 18.3252 13.6504 18.3252ZM17.6504 17.8252C17.8297 17.8254 17.9746 17.971 17.9746 18.1504C17.9744 18.3296 17.8296 18.4744 17.6504 18.4746C17.471 18.4746 17.3254 18.3297 17.3252 18.1504C17.3252 17.9709 17.4709 17.8252 17.6504 17.8252ZM7.65039 17.3252C7.8297 17.3254 7.97461 17.471 7.97461 17.6504C7.9744 17.8296 7.82957 17.9744 7.65039 17.9746C7.47103 17.9746 7.32541 17.8297 7.3252 17.6504C7.3252 17.4709 7.4709 17.3252 7.65039 17.3252ZM11.6504 16.3252C11.8297 16.3254 11.9746 16.471 11.9746 16.6504C11.9744 16.8296 11.8296 16.9744 11.6504 16.9746C11.471 16.9746 11.3254 16.8297 11.3252 16.6504C11.3252 16.4709 11.4709 16.3252 11.6504 16.3252ZM15.6504 16.3252C15.8297 16.3254 15.9746 16.471 15.9746 16.6504C15.9744 16.8296 15.8296 16.9744 15.6504 16.9746C15.471 16.9746 15.3254 16.8297 15.3252 16.6504C15.3252 16.4709 15.4709 16.3252 15.6504 16.3252ZM18.6504 15.8252C18.8297 15.8254 18.9746 15.971 18.9746 16.1504C18.9744 16.3296 18.8296 16.4744 18.6504 16.4746C18.471 16.4746 18.3254 16.3297 18.3252 16.1504C18.3252 15.9709 18.4709 15.8252 18.6504 15.8252ZM9.65039 14.3252C9.8297 14.3254 9.97461 14.471 9.97461 14.6504C9.9744 14.8296 9.82957 14.9744 9.65039 14.9746C9.47103 14.9746 9.32541 14.8297 9.3252 14.6504C9.3252 14.4709 9.4709 14.3252 9.65039 14.3252ZM13.6504 14.3252C13.8297 14.3254 13.9746 14.471 13.9746 14.6504C13.9744 14.8296 13.8296 14.9744 13.6504 14.9746C13.471 14.9746 13.3254 14.8297 13.3252 14.6504C13.3252 14.4709 13.4709 14.3252 13.6504 14.3252ZM17.6504 13.8252C17.8297 13.8254 17.9746 13.971 17.9746 14.1504C17.9744 14.3296 17.8296 14.4744 17.6504 14.4746C17.471 14.4746 17.3254 14.3297 17.3252 14.1504C17.3252 13.9709 17.4709 13.8252 17.6504 13.8252ZM15.6504 12.3252C15.8297 12.3254 15.9746 12.471 15.9746 12.6504C15.9744 12.8296 15.8296 12.9744 15.6504 12.9746C15.471 12.9746 15.3254 12.8297 15.3252 12.6504C15.3252 12.4709 15.4709 12.3252 15.6504 12.3252ZM18.6504 11.8252C18.8297 11.8254 18.9746 11.971 18.9746 12.1504C18.9744 12.3296 18.8296 12.4744 18.6504 12.4746C18.471 12.4746 18.3254 12.3297 18.3252 12.1504C18.3252 11.9709 18.4709 11.8252 18.6504 11.8252ZM11.6504 11.3252C11.8297 11.3254 11.9746 11.471 11.9746 11.6504C11.9744 11.8296 11.8296 11.9744 11.6504 11.9746C11.471 11.9746 11.3254 11.8297 11.3252 11.6504C11.3252 11.4709 11.4709 11.3252 11.6504 11.3252ZM17.6504 9.8252C17.8297 9.82541 17.9746 9.97103 17.9746 10.1504C17.9744 10.3296 17.8296 10.4744 17.6504 10.4746C17.471 10.4746 17.3254 10.3297 17.3252 10.1504C17.3252 9.9709 17.4709 9.8252 17.6504 9.8252ZM7.65039 9.3252C7.8297 9.32541 7.97461 9.47103 7.97461 9.65039C7.9744 9.82957 7.82957 9.9744 7.65039 9.97461C7.47103 9.97461 7.32541 9.8297 7.3252 9.65039C7.3252 9.4709 7.4709 9.3252 7.65039 9.3252ZM13.6504 9.3252C13.8297 9.32541 13.9746 9.47103 13.9746 9.65039C13.9744 9.82957 13.8296 9.9744 13.6504 9.97461C13.471 9.97461 13.3254 9.8297 13.3252 9.65039C13.3252 9.4709 13.4709 9.3252 13.6504 9.3252ZM18.6504 7.8252C18.8297 7.82541 18.9746 7.97103 18.9746 8.15039C18.9744 8.32957 18.8296 8.4744 18.6504 8.47461C18.471 8.47461 18.3254 8.3297 18.3252 8.15039C18.3252 7.9709 18.4709 7.8252 18.6504 7.8252ZM11.6504 7.3252C11.8297 7.32541 11.9746 7.47103 11.9746 7.65039C11.9744 7.82957 11.8296 7.9744 11.6504 7.97461C11.471 7.97461 11.3254 7.8297 11.3252 7.65039C11.3252 7.4709 11.4709 7.3252 11.6504 7.3252ZM15.6504 7.3252C15.8297 7.32541 15.9746 7.47103 15.9746 7.65039C15.9744 7.82957 15.8296 7.9744 15.6504 7.97461C15.471 7.97461 15.3254 7.8297 15.3252 7.65039C15.3252 7.4709 15.4709 7.3252 15.6504 7.3252ZM17.6504 5.8252C17.8297 5.82541 17.9746 5.97103 17.9746 6.15039C17.9744 6.32957 17.8296 6.4744 17.6504 6.47461C17.471 6.47461 17.3254 6.3297 17.3252 6.15039C17.3252 5.9709 17.4709 5.8252 17.6504 5.8252ZM9.65039 5.3252C9.8297 5.32541 9.97461 5.47103 9.97461 5.65039C9.9744 5.82957 9.82957 5.9744 9.65039 5.97461C9.47103 5.97461 9.32541 5.8297 9.3252 5.65039C9.3252 5.4709 9.4709 5.3252 9.65039 5.3252ZM13.6504 5.3252C13.8297 5.32541 13.9746 5.47103 13.9746 5.65039C13.9744 5.82957 13.8296 5.9744 13.6504 5.97461C13.471 5.97461 13.3254 5.8297 13.3252 5.65039C13.3252 5.4709 13.4709 5.3252 13.6504 5.3252Z" stroke="white" stroke-width="0.65"/>
<path d="M19 3H5C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6504 18.3252C13.8297 18.3254 13.9746 18.471 13.9746 18.6504C13.9744 18.8296 13.8296 18.9744 13.6504 18.9746C13.471 18.9746 13.3254 18.8297 13.3252 18.6504C13.3252 18.4709 13.4709 18.3252 13.6504 18.3252ZM17.6504 17.8252C17.8297 17.8254 17.9746 17.971 17.9746 18.1504C17.9744 18.3296 17.8296 18.4744 17.6504 18.4746C17.471 18.4746 17.3254 18.3297 17.3252 18.1504C17.3252 17.9709 17.4709 17.8252 17.6504 17.8252ZM7.65039 17.3252C7.8297 17.3254 7.97461 17.471 7.97461 17.6504C7.9744 17.8296 7.82957 17.9744 7.65039 17.9746C7.47103 17.9746 7.32541 17.8297 7.3252 17.6504C7.3252 17.4709 7.4709 17.3252 7.65039 17.3252ZM11.6504 16.3252C11.8297 16.3254 11.9746 16.471 11.9746 16.6504C11.9744 16.8296 11.8296 16.9744 11.6504 16.9746C11.471 16.9746 11.3254 16.8297 11.3252 16.6504C11.3252 16.4709 11.4709 16.3252 11.6504 16.3252ZM15.6504 16.3252C15.8297 16.3254 15.9746 16.471 15.9746 16.6504C15.9744 16.8296 15.8296 16.9744 15.6504 16.9746C15.471 16.9746 15.3254 16.8297 15.3252 16.6504C15.3252 16.4709 15.4709 16.3252 15.6504 16.3252ZM18.6504 15.8252C18.8297 15.8254 18.9746 15.971 18.9746 16.1504C18.9744 16.3296 18.8296 16.4744 18.6504 16.4746C18.471 16.4746 18.3254 16.3297 18.3252 16.1504C18.3252 15.9709 18.4709 15.8252 18.6504 15.8252ZM9.65039 14.3252C9.8297 14.3254 9.97461 14.471 9.97461 14.6504C9.9744 14.8296 9.82957 14.9744 9.65039 14.9746C9.47103 14.9746 9.32541 14.8297 9.3252 14.6504C9.3252 14.4709 9.4709 14.3252 9.65039 14.3252ZM13.6504 14.3252C13.8297 14.3254 13.9746 14.471 13.9746 14.6504C13.9744 14.8296 13.8296 14.9744 13.6504 14.9746C13.471 14.9746 13.3254 14.8297 13.3252 14.6504C13.3252 14.4709 13.4709 14.3252 13.6504 14.3252ZM17.6504 13.8252C17.8297 13.8254 17.9746 13.971 17.9746 14.1504C17.9744 14.3296 17.8296 14.4744 17.6504 14.4746C17.471 14.4746 17.3254 14.3297 17.3252 14.1504C17.3252 13.9709 17.4709 13.8252 17.6504 13.8252ZM15.6504 12.3252C15.8297 12.3254 15.9746 12.471 15.9746 12.6504C15.9744 12.8296 15.8296 12.9744 15.6504 12.9746C15.471 12.9746 15.3254 12.8297 15.3252 12.6504C15.3252 12.4709 15.4709 12.3252 15.6504 12.3252ZM18.6504 11.8252C18.8297 11.8254 18.9746 11.971 18.9746 12.1504C18.9744 12.3296 18.8296 12.4744 18.6504 12.4746C18.471 12.4746 18.3254 12.3297 18.3252 12.1504C18.3252 11.9709 18.4709 11.8252 18.6504 11.8252ZM11.6504 11.3252C11.8297 11.3254 11.9746 11.471 11.9746 11.6504C11.9744 11.8296 11.8296 11.9744 11.6504 11.9746C11.471 11.9746 11.3254 11.8297 11.3252 11.6504C11.3252 11.4709 11.4709 11.3252 11.6504 11.3252ZM17.6504 9.8252C17.8297 9.82541 17.9746 9.97103 17.9746 10.1504C17.9744 10.3296 17.8296 10.4744 17.6504 10.4746C17.471 10.4746 17.3254 10.3297 17.3252 10.1504C17.3252 9.9709 17.4709 9.8252 17.6504 9.8252ZM7.65039 9.3252C7.8297 9.32541 7.97461 9.47103 7.97461 9.65039C7.9744 9.82957 7.82957 9.9744 7.65039 9.97461C7.47103 9.97461 7.32541 9.8297 7.3252 9.65039C7.3252 9.4709 7.4709 9.3252 7.65039 9.3252ZM13.6504 9.3252C13.8297 9.32541 13.9746 9.47103 13.9746 9.65039C13.9744 9.82957 13.8296 9.9744 13.6504 9.97461C13.471 9.97461 13.3254 9.8297 13.3252 9.65039C13.3252 9.4709 13.4709 9.3252 13.6504 9.3252ZM18.6504 7.8252C18.8297 7.82541 18.9746 7.97103 18.9746 8.15039C18.9744 8.32957 18.8296 8.4744 18.6504 8.47461C18.471 8.47461 18.3254 8.3297 18.3252 8.15039C18.3252 7.9709 18.4709 7.8252 18.6504 7.8252ZM11.6504 7.3252C11.8297 7.32541 11.9746 7.47103 11.9746 7.65039C11.9744 7.82957 11.8296 7.9744 11.6504 7.97461C11.471 7.97461 11.3254 7.8297 11.3252 7.65039C11.3252 7.4709 11.4709 7.3252 11.6504 7.3252ZM15.6504 7.3252C15.8297 7.32541 15.9746 7.47103 15.9746 7.65039C15.9744 7.82957 15.8296 7.9744 15.6504 7.97461C15.471 7.97461 15.3254 7.8297 15.3252 7.65039C15.3252 7.4709 15.4709 7.3252 15.6504 7.3252ZM17.6504 5.8252C17.8297 5.82541 17.9746 5.97103 17.9746 6.15039C17.9744 6.32957 17.8296 6.4744 17.6504 6.47461C17.471 6.47461 17.3254 6.3297 17.3252 6.15039C17.3252 5.9709 17.4709 5.8252 17.6504 5.8252ZM9.65039 5.3252C9.8297 5.32541 9.97461 5.47103 9.97461 5.65039C9.9744 5.82957 9.82957 5.9744 9.65039 5.97461C9.47103 5.97461 9.32541 5.8297 9.3252 5.65039C9.3252 5.4709 9.4709 5.3252 9.65039 5.3252ZM13.6504 5.3252C13.8297 5.32541 13.9746 5.47103 13.9746 5.65039C13.9744 5.82957 13.8296 5.9744 13.6504 5.97461C13.471 5.97461 13.3254 5.8297 13.3252 5.65039C13.3252 5.4709 13.4709 5.3252 13.6504 5.3252Z" stroke="currentColor" stroke-width="0.65"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,6 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1471_12732)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M197.76 327.187L367.957 201.384C376.299 195.197 388.224 197.608 392.213 207.187C413.12 257.725 403.776 318.44 362.133 360.125C320.512 401.811 262.571 410.941 209.621 390.12L151.787 416.936C234.752 473.704 335.488 459.667 398.443 396.605C448.384 346.6 463.851 278.44 449.387 216.979L449.515 217.128C428.544 126.845 454.677 90.7493 508.181 16.9573C509.461 15.208 510.741 13.4586 512 11.6666L441.579 82.1733V81.96L197.696 327.229M162.624 357.757C103.061 300.797 113.344 212.669 164.139 161.832C201.707 124.221 263.275 108.861 317.013 131.432L374.72 104.765C362.726 95.9422 349.603 88.7672 335.701 83.432C300.753 69.1279 262.355 65.4777 225.337 72.9405C188.32 80.4032 154.335 98.6457 127.659 125.373C73.6213 179.475 56.6187 262.675 85.8027 333.672C107.605 386.728 71.872 424.253 35.8827 462.141C23.104 475.581 10.304 489 0 503.208L162.56 357.821" fill="#B6B6B6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M197.76 327.187L367.957 201.384C376.299 195.197 388.224 197.608 392.213 207.187C413.12 257.725 403.776 318.44 362.133 360.125C320.512 401.811 262.571 410.941 209.621 390.12L151.787 416.936C234.752 473.704 335.488 459.667 398.443 396.605C448.384 346.6 463.851 278.44 449.387 216.979L449.515 217.128C428.544 126.845 454.677 90.7493 508.181 16.9573C509.461 15.208 510.741 13.4586 512 11.6666L441.579 82.1733V81.96L197.696 327.229M162.624 357.757C103.061 300.797 113.344 212.669 164.139 161.832C201.707 124.221 263.275 108.861 317.013 131.432L374.72 104.765C362.726 95.9422 349.603 88.7672 335.701 83.432C300.753 69.1279 262.355 65.4777 225.337 72.9405C188.32 80.4032 154.335 98.6457 127.659 125.373C73.6213 179.475 56.6187 262.675 85.8027 333.672C107.605 386.728 71.872 424.253 35.8827 462.141C23.104 475.581 10.304 489 0 503.208L162.56 357.821" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_1471_12732">

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,6 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1471_12743)">
<path d="M148.228 255.643L102.567 278.057L99.924 279.348L64.0174 296.98V325.672L113.435 301.418L117.364 299.492L139.326 288.706L145.968 285.454C148.837 273.378 152.193 262.214 155.914 251.876L148.228 255.643ZM432.49 117.736C431.012 114.312 429.43 110.958 427.761 107.637C427.552 107.225 427.343 106.777 427.1 106.364C408.825 70.8767 379.613 41.7882 343.81 23.4336C343.115 23.0725 318.563 11.8911 285.838 5.73274C276.222 3.92656 265.929 2.55047 255.305 2H122.372C90.1518 2 64.0174 27.7858 64.0174 59.5923V260.77L87.5957 249.209L88.6216 248.71L172.068 207.753L178.397 204.639C213.016 148.337 255.861 133.664 255.861 133.664V166.537C270.05 167.191 282.447 172.868 290.394 181.193C293.768 184.686 296.636 189.691 298.74 194.782C299.14 195.78 299.522 196.761 299.854 197.759C300.532 199.633 301.071 201.457 301.487 203.195C302.079 205.603 303.07 207.581 304.287 209.335C304.287 209.335 304.287 209.388 304.323 209.405C305.521 211.107 306.93 212.553 308.408 213.929C313.555 218.745 319.433 222.564 319.433 231.853C319.433 236.05 317.607 239.68 314.476 242.862C312.72 244.668 310.565 246.303 308.095 247.851C307.556 248.177 306.983 248.504 306.408 248.814L306.339 248.865C305.244 249.468 304.078 250.069 302.896 250.638C302.653 250.758 302.392 250.861 302.131 250.982C302.079 250.999 302.009 251.016 301.957 251.05C294.567 254.387 278.866 259.48 275.927 260.391C275.231 260.58 274.432 260.804 273.579 261.044C270.919 261.802 267.789 262.696 265.929 263.246C265.72 263.281 265.529 263.35 265.338 263.418C264.729 263.608 264.364 263.728 264.33 263.745C262.885 264.176 261.478 264.691 260.138 265.311C259.791 265.466 259.443 265.638 259.096 265.81C255.409 267.581 252.071 269.937 249.184 272.794C242.455 279.416 238.317 288.534 238.317 298.63C238.317 303.655 239.378 308.47 241.22 312.858C242.194 315.025 243.342 317.106 244.629 319.101C246.262 321.785 248.158 324.485 250.245 327.221C250.488 327.582 250.767 327.926 251.027 328.27C258.139 337.473 267.32 346.969 276.622 356.757C279.804 360.094 283.005 363.448 286.117 366.854C288.881 369.864 291.628 372.91 294.271 375.988C295.054 376.882 295.837 377.795 296.585 378.706C301.523 377.657 313.033 374.388 314.094 374.061C340.419 365.737 364.311 351.94 384.36 334.033C384.655 333.792 384.933 333.552 385.229 333.293C421.258 300.73 444.75 254.852 447.687 203.573C447.862 200.614 447.949 197.673 447.983 194.68C448 194.009 448 193.32 448 192.632C447.949 166.038 442.453 140.717 432.49 117.736ZM135.605 326.739L135.448 326.808L64.0174 361.848V390.541L135.326 355.552C135.552 352.25 135.831 348.947 136.161 345.576C136.196 344.99 136.265 344.389 136.317 343.803L136.387 343.081C136.822 338.54 137.361 333.93 137.987 329.234C138.161 327.943 138.334 326.618 138.526 325.311L135.605 326.739ZM156.522 505.434C155.775 503.696 154.958 501.717 154.088 499.464C147.394 482.176 137.517 449.923 134.996 405.214C134.753 400.983 134.579 396.665 134.474 392.227L68.6426 424.481L64 426.768V456.391C64 463.942 65.4606 471.132 68.1558 477.739C76.7282 498.983 97.7679 514 122.355 514H160.452V513.828C160.104 513.157 158.627 510.336 156.522 505.434Z" fill="#B6B6B6"/>
<path d="M148.228 255.643L102.567 278.057L99.924 279.348L64.0174 296.98V325.672L113.435 301.418L117.364 299.492L139.326 288.706L145.968 285.454C148.837 273.378 152.193 262.214 155.914 251.876L148.228 255.643ZM432.49 117.736C431.012 114.312 429.43 110.958 427.761 107.637C427.552 107.225 427.343 106.777 427.1 106.364C408.825 70.8767 379.613 41.7882 343.81 23.4336C343.115 23.0725 318.563 11.8911 285.838 5.73274C276.222 3.92656 265.929 2.55047 255.305 2H122.372C90.1518 2 64.0174 27.7858 64.0174 59.5923V260.77L87.5957 249.209L88.6216 248.71L172.068 207.753L178.397 204.639C213.016 148.337 255.861 133.664 255.861 133.664V166.537C270.05 167.191 282.447 172.868 290.394 181.193C293.768 184.686 296.636 189.691 298.74 194.782C299.14 195.78 299.522 196.761 299.854 197.759C300.532 199.633 301.071 201.457 301.487 203.195C302.079 205.603 303.07 207.581 304.287 209.335C304.287 209.335 304.287 209.388 304.323 209.405C305.521 211.107 306.93 212.553 308.408 213.929C313.555 218.745 319.433 222.564 319.433 231.853C319.433 236.05 317.607 239.68 314.476 242.862C312.72 244.668 310.565 246.303 308.095 247.851C307.556 248.177 306.983 248.504 306.408 248.814L306.339 248.865C305.244 249.468 304.078 250.069 302.896 250.638C302.653 250.758 302.392 250.861 302.131 250.982C302.079 250.999 302.009 251.016 301.957 251.05C294.567 254.387 278.866 259.48 275.927 260.391C275.231 260.58 274.432 260.804 273.579 261.044C270.919 261.802 267.789 262.696 265.929 263.246C265.72 263.281 265.529 263.35 265.338 263.418C264.729 263.608 264.364 263.728 264.33 263.745C262.885 264.176 261.478 264.691 260.138 265.311C259.791 265.466 259.443 265.638 259.096 265.81C255.409 267.581 252.071 269.937 249.184 272.794C242.455 279.416 238.317 288.534 238.317 298.63C238.317 303.655 239.378 308.47 241.22 312.858C242.194 315.025 243.342 317.106 244.629 319.101C246.262 321.785 248.158 324.485 250.245 327.221C250.488 327.582 250.767 327.926 251.027 328.27C258.139 337.473 267.32 346.969 276.622 356.757C279.804 360.094 283.005 363.448 286.117 366.854C288.881 369.864 291.628 372.91 294.271 375.988C295.054 376.882 295.837 377.795 296.585 378.706C301.523 377.657 313.033 374.388 314.094 374.061C340.419 365.737 364.311 351.94 384.36 334.033C384.655 333.792 384.933 333.552 385.229 333.293C421.258 300.73 444.75 254.852 447.687 203.573C447.862 200.614 447.949 197.673 447.983 194.68C448 194.009 448 193.32 448 192.632C447.949 166.038 442.453 140.717 432.49 117.736ZM135.605 326.739L135.448 326.808L64.0174 361.848V390.541L135.326 355.552C135.552 352.25 135.831 348.947 136.161 345.576C136.196 344.99 136.265 344.389 136.317 343.803L136.387 343.081C136.822 338.54 137.361 333.93 137.987 329.234C138.161 327.943 138.334 326.618 138.526 325.311L135.605 326.739ZM156.522 505.434C155.775 503.696 154.958 501.717 154.088 499.464C147.394 482.176 137.517 449.923 134.996 405.214C134.753 400.983 134.579 396.665 134.474 392.227L68.6426 424.481L64 426.768V456.391C64 463.942 65.4606 471.132 68.1558 477.739C76.7282 498.983 97.7679 514 122.355 514H160.452V513.828C160.104 513.157 158.627 510.336 156.522 505.434Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_1471_12743">

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M20 15C20.5523 15 21 14.5523 21 14V5C21 4.46957 20.7893 3.96086 20.4142 3.58579C20.0391 3.21071 19.5304 3 19 3H10C9.44772 3 9 3.44772 9 4M17 18C17.5523 18 18 17.5523 18 17V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H7C6.44772 6 6 6.44772 6 7M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 17L12.9427 14.9426C12.6926 14.6927 12.3536 14.5522 12 14.5522C11.6464 14.5522 11.3074 14.6927 11.0573 14.9426L5 21M20 15C20.5523 15 21 14.5523 21 14V5C21 4.46957 20.7893 3.96086 20.4142 3.58579C20.0391 3.21071 19.5304 3 19 3H10C9.44772 3 9 3.44772 9 4M17 18C17.5523 18 18 17.5523 18 17V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H7C6.44772 6 6 6.44772 6 7M4.33333 9H13.6667C14.403 9 15 9.59695 15 10.3333V19.6667C15 20.403 14.403 21 13.6667 21H4.33333C3.59695 21 3 20.403 3 19.6667V10.3333C3 9.59695 3.59695 9 4.33333 9ZM8.33333 13C8.33333 13.7364 7.73638 14.3333 7 14.3333C6.26362 14.3333 5.66667 13.7364 5.66667 13C5.66667 12.2636 6.26362 11.6667 7 11.6667C7.73638 11.6667 8.33333 12.2636 8.33333 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 942 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.4292 17.4286C12.4292 17.4286 10.7681 17.4818 9.85773 16.5714C8.94733 15.661 9.00058 14.8571 9.00058 14M12 20.6429C13.5913 20.6429 15.1174 20.0107 16.2426 18.8855C17.3679 17.7603 18 16.2342 18 14.6429C18 9.5 12 4.35715 12 4.35715C12 4.35715 6 9.5 6 14.6429C6 16.2342 6.63214 17.7603 7.75736 18.8855C8.88258 20.0107 10.4087 20.6429 12 20.6429Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.4292 17.4286C12.4292 17.4286 10.7681 17.4818 9.85773 16.5714C8.94733 15.661 9.00058 14.8571 9.00058 14M12 20.6429C13.5913 20.6429 15.1174 20.0107 16.2426 18.8855C17.3679 17.7603 18 16.2342 18 14.6429C18 9.5 12 4.35715 12 4.35715C12 4.35715 6 9.5 6 14.6429C6 16.2342 6.63214 17.7603 7.75736 18.8855C8.88258 20.0107 10.4087 20.6429 12 20.6429Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 542 B

After

Width:  |  Height:  |  Size: 549 B

View File

@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2508 12.7276C12.4587 13.0908 11.5409 13.0908 10.7488 12.7276M10.7488 7.2724C11.5409 6.9092 12.4587 6.9092 13.2508 7.2724M9.27202 11.251C8.90883 10.4589 8.90883 9.54111 9.27202 8.74897M14.7272 8.74897C15.0904 9.54111 15.0904 10.4589 14.7272 11.251M6.18003 19.5412C6.06141 20.0143 6 20.504 6 21M18 21C18 20.504 17.9386 20.0143 17.82 19.5412M7 17.6833C7.21936 17.3526 7.47257 17.0421 7.75736 16.7574C8.04215 16.4726 8.35262 16.2194 8.68333 16M17 17.6833C16.7806 17.3526 16.5274 17.0421 16.2426 16.7574C15.9579 16.4726 15.6474 16.2194 15.3167 16M10.6747 15.1482C11.1062 15.0504 11.5505 15 12 15C12.4495 15 12.8938 15.0504 13.3253 15.1482M5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.2508 12.7276C12.4587 13.0908 11.5409 13.0908 10.7488 12.7276M10.7488 7.2724C11.5409 6.9092 12.4587 6.9092 13.2508 7.2724M9.27202 11.251C8.90883 10.4589 8.90883 9.54111 9.27202 8.74897M14.7272 8.74897C15.0904 9.54111 15.0904 10.4589 14.7272 11.251M6.18003 19.5412C6.06141 20.0143 6 20.504 6 21M18 21C18 20.504 17.9386 20.0143 17.82 19.5412M7 17.6833C7.21936 17.3526 7.47257 17.0421 7.75736 16.7574C8.04215 16.4726 8.35262 16.2194 8.68333 16M17 17.6833C16.7806 17.3526 16.5274 17.0421 16.2426 16.7574C15.9579 16.4726 15.6474 16.2194 15.3167 16M10.6747 15.1482C11.1062 15.0504 11.5505 15 12 15C12.4495 15 12.8938 15.0504 13.3253 15.1482M5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 960 B

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