Compare commits

..

24 Commits

Author SHA1 Message Date
DrJKL
ba7a45239a Don't mix "clean" getter with get accessor 2026-05-04 22:09:47 -07:00
DrJKL
b8bb67bcc7 remove silly test 2026-05-04 19:27:52 -07:00
Alexander Brown
9e7ea6a9d9 Update browser_tests/tests/subgraph/subgraphSerialization.spec.ts
Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-04 19:11:23 -07:00
DrJKL
83b2104983 test: e2e regression for promoted-widget sparse-override (PR #11811)
Adds a Playwright spec that pins PR #11811 (commit 66c89c8e5):

- Loads value-application.json fixture: outer SubgraphNode (id=4,
  widgets_values=["exterior"], proxyWidgets=[["2", "value"]]) over an
  interior PrimitiveNode (id=2, widgets_values=["interior"]) feeding
  PrimitiveStringMultiline (id=1) into PreviewAny (id=3). PrimitiveNode
  is the lazy-widget-creation case from src/extensions/core/widgetInputs.ts
  that triggered the original bug.

- Test 1: Vue node UI for the SubgraphNode renders the promoted "value"
  textbox with "exterior", not "interior". Pins the load-time deferred
  replay in SubgraphNode.onAfterGraphConfigured against PrimitiveNode's
  onAfterGraphConfigured race.

- Test 2: graphToPrompt's getExecutableWidgetValue ancestor walk picks
  up the per-instance override during prompt-build — the
  PrimitiveStringMultiline's `inputs.value` in the api prompt resolves
  to "exterior".

- Test 3: editing the promoted textbox to "edited" propagates through
  PromotedWidgetView setter write-through; api prompt reflects "edited".

- Test 4: reloading the workflow without saving resets the promoted
  widget back to the exterior override.

Fixture under browser_tests/assets/subgraphs/, spec under
browser_tests/tests/subgraph/, follows existing patterns from
subgraphMultiInstanceVueWidgets.spec.ts and subgraphSerialization.spec.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019df5d3-9cb4-72ed-892e-2e2eb28a6b52
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 19:02:28 -07:00
DrJKL
66c89c8e52 fix: propagate promoted-widget overrides to interior on subgraph load
Sparse-override semantics for PromotedWidgetView:

- SubgraphNode.onAfterGraphConfigured: deferred replay walks promoted
  views and re-applies view.value = perInstanceOverrideValue after
  lazy-creation interiors (e.g. PrimitiveNode in widgetInputs.ts) have
  materialized their widgets and reapplied their widgets_values. Wins
  both the materialization race and the post-restore clobber.
- PromotedWidgetView.set value: per-instance override write +
  write-through to interior widget via resolveAtHost. Chains through
  nested promoted views down to the concrete interior widget.
- PromotedWidgetView.get value: removed eager seed. Sparse-override
  semantics — an override exists only when explicitly written or
  hydrated by configure replay; otherwise reads fall through to the
  live interior widget.
- executionUtil.graphToPrompt: getExecutableWidgetValue walks the
  ancestor SubgraphNode chain and returns the per-instance override
  when one applies, mirroring Vue/canvas read semantics so prompt-build
  does not desync from on-screen values.
- useProcessedWidgets: fallback widget-state lookup so freshly-created
  promoted views read the interior WidgetState entry until they acquire
  their own override.

Tests:

- New promotedWidgetView.lazyInterior.test.ts pins the lazy-widget-
  creation race using a LazyPrimitiveLikeNode fixture.
- executionUtil.test.ts gains an integration test for graphToPrompt
  with the same lazy interior.
- 9 existing tests across promotedWidgetView.test.ts (6),
  subgraphNodePromotion.test.ts (2), and SubgraphNode.multiInstance
  .test.ts (1) updated to reflect the new design intent: exterior
  writes propagate to interior; sparse-override means fresh sibling
  instances follow the shared interior until they acquire an explicit
  override.

Terminology cleanup: replaced informal "cell" vocabulary with
"per-instance override" / "WidgetState entry" / "store entry" across
production code, tests, and investigation notes.

Amp-Thread-ID: https://ampcode.com/threads/T-019df5c0-ff16-718d-8b75-ed15a7c390c7
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 18:48:45 -07:00
DrJKL
676a392187 fix(subgraph): address PR review follow-ups
- useGraphNodeManager: guard String(undefined) on promoted source node id
- SubgraphNode.serialize: preserve null widget values (drop ?? undefined)
- SubgraphNode: trim verbose JSDoc on _pendingWidgetsValuesReplay
- promotedWidgetView.findBoundSubgraphSlot: collapse double-loop into one pass
- promotedWidgetView.set label: lift asymmetry rationale to JSDoc
- widgetValueStore.getOrRegister: drop unsound <TValue> cast; document
  register-wins semantics
- legacyProxyWidgetNormalization: document regex collision trade-off
- useProcessedWidgets test: align STORE_NAME with real makeCompositeKey shape

Amp-Thread-ID: https://ampcode.com/threads/T-019df4ad-2bed-75ad-91b2-4ef5886b48fa
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 13:51:19 -07:00
Alexander Brown
4b2ae3fd84 Merge branch 'main' into drjkl/subgraph-instance-store 2026-05-04 13:47:42 -07:00
Alexander Brown
6a33fd4a05 Merge branch 'main' into drjkl/subgraph-instance-store 2026-05-04 10:07:48 -07:00
DrJKL
e8272776aa fix(subgraph): address review follow-ups
Amp-Thread-ID: https://ampcode.com/threads/T-019df186-0508-73cc-a368-5e08fc65fea1
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 00:20:41 -07:00
Alexander Brown
0c0c3d9f2e Merge branch 'main' into drjkl/subgraph-instance-store 2026-05-03 21:16:55 -07:00
DrJKL
7dd1b7da4b fix(schema): trim unused promotion exports
Amp-Thread-ID: https://ampcode.com/threads/T-019df068-e630-74a6-99f3-85ade098bad8
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 19:14:16 -07:00
DrJKL
a9213760c8 test(subgraph): cover legacy 3-tuple save
Amp-Thread-ID: https://ampcode.com/threads/T-019df068-e630-74a6-99f3-85ade098bad8
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 19:10:57 -07:00
DrJKL
ae506465c7 fix(subgraph): serialize proxyWidgets as 2-tuples
Amp-Thread-ID: https://ampcode.com/threads/T-019de7d5-b234-758d-9af5-04d5759c2a43
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 17:33:57 -07:00
DrJKL
c27921f172 refactor(subgraph): extract makeCompositeKey utility for opaque tuple keys
Three places in the codebase JSON.stringify arrays to build opaque
composite Map/Set keys (favoritedWidgetsStore, SubgraphNode promotion
view keys, promoted-widget storeName). Extract a shared helper.

Reverts storeName from NUL-delimited back to JSON.stringify(array)
for consistency with the established codebase pattern. Still computed
once in the constructor.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:59:24 -07:00
DrJKL
7f80f72a62 Revert "fix(subgraph): drop unreachable typeof guards in resolveNodeWidget"
This reverts commit d6c9565622.
2026-05-01 23:51:36 -07:00
DrJKL
a0470f5116 test(subgraph): cover multi-instance promoted-widget selection round-trip
Two SubgraphNode instances of the same definition share the same
promoted-widget storeName but distinct host ids. Pin the contract
that loadSelections preserves both as distinct entries through
pruneLinearData + normalizeSelectedInput.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:47:29 -07:00
DrJKL
519191b5f6 refactor(subgraph): keep SubgraphNode.serialize() pure
The promotion-store is the runtime source of truth after the recent
fix; mutating this.properties.proxyWidgets in serialize() leaves a
stale mirror nothing reads. Write the snapshot to the serialized
output instead. Drop the dead [...existing] spread on widgets_values
since SubgraphNode never sets serialize_widgets, so super.serialize()
never produces it.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:42:49 -07:00
DrJKL
dbabf2de3c perf(subgraph): memoize promoted-widget storeName in constructor
storeName was JSON.stringify'd on every read; it's read inside the
Vue render cycle. The fields composing it are readonly. Compute
once in the constructor with a NUL separator that cannot legally
appear in widget names and is visibly not JSON.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:37:59 -07:00
DrJKL
d6c9565622 fix(subgraph): drop unreachable typeof guards in resolveNodeWidget
The typeof !== 'function' checks added in the prior promoted-widget
fix mask non-existent failure modes — every caller passes a real
LGraph/LGraphNode. Keep only the legitimate null-check that pairs
with the widened nullable graph parameter.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:35:03 -07:00
DrJKL
d1fb94dc7d fix(litegraph): dispatch trigger() events on graph.events bus
LGraph.trigger() now dispatches typed events on the existing
graph.events CustomEventTarget in addition to calling the legacy
onTrigger field, so consumers can subscribe without monkey-patching.

Migrate AppModeWidgetList to useEventListener on graph.events for
node:slot-label:changed, eliminating its rootGraph.onTrigger chain
that was vulnerable to non-LIFO disposal corruption.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:31:10 -07:00
DrJKL
71cb98f7b3 fix(subgraph): defer widgets_values replay until SubgraphNode is attached
LGraphNode.clone() invokes configure() before the cloned node has a real
id. The setter on PromotedWidgetView guards id === -1 (to avoid orphan
cells in widgetValueStore), which silently dropped the replayed values.
Stash widgets_values during configure() if id === -1; flush on onAdded().

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:24:11 -07:00
DrJKL
559bc10b94 fix: subgraph promoted-widget rendering, value lookup, and rename
Fixes three deterministic Playwright failures introduced by the
subgraph-instance-store identity changes:

- `useProcessedWidgets.getWidgetIdentity` no longer falls back to the
  host nodeId for ordinary widgets. Stable identity is reserved for
  promoted views (which carry their own `widget.nodeId`); ordinary
  widgets fall through to the transient `index`-based renderKey.
  Fixes Vue Widget Reactivity "Should display added widgets" where
  duplicate widget refs were being collapsed by the dedupe loop.

- `computeProcessedWidgets` now falls back to the interior cell
  `(graphId, source.sourceNodeId, source.sourceWidgetName)` when the
  per-instance host cell is absent. Reads only — the per-instance
  write path is unchanged. Fixes the nested-subgraph promoted-widget
  values test where freshly-created promoted widgets rendered blank
  before pack.

- `AppModeWidgetList` chains `rootGraph.onTrigger` to bump a
  `labelVersion` ref on `node:slot-label:changed`, so `mappedSelections`
  re-evaluates after a widget rename. The chained handler preserves
  `useGraphNodeManager`'s existing handler chain and is restored on
  scope dispose.

- `PromotedWidgetView.label` setter now materializes the per-instance
  state cell when no subgraph slot is bound, instead of silently
  no-opping. The getter already falls back to `state?.label` when no
  slot is found, so renames on selected (non-IO) promoted widgets are
  now visible end-to-end. Fixes all four App-mode-widget-rename tests.

Includes regression tests for the dedupe and promoted-value paths in
`useProcessedWidgets.test.ts`, and updates the
`PromotedWidgetView` label-setter test to lock in the new invariant.

Amp-Thread-ID: https://ampcode.com/threads/T-019de643-67dc-77fe-ac05-2effc45a5b15
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 22:40:26 -07:00
DrJKL
07c3b324ca fix(subgraph): isolate promoted widget state
Amp-Thread-ID: https://ampcode.com/threads/T-019de5de-4b7c-708f-8bb8-10cd91287379
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 17:25:17 -07:00
DrJKL
2ab95698cf fix(subgraph): restore promoted widget instance state
Amp-Thread-ID: https://ampcode.com/threads/T-019de596-2219-76a4-96fc-0350ab5a83cc
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 15:58:23 -07:00
2551 changed files with 63033 additions and 292297 deletions

View File

@@ -33,15 +33,15 @@ Flag:
- **New circular entity dependencies** — New circular imports between `LGraph``Subgraph`, `LGraphNode``LGraphCanvas`, or similar entity classes.
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
### Dedicated Stores and Data/Behavior Separation
### Centralized Registries and ECS-Style Access
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
All entity data access should move toward centralized query patterns, not instance property access.
Flag:
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
### Extension Ecosystem Impact

86
.claude/settings.json Normal file
View File

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

View File

@@ -1,173 +0,0 @@
---
name: add-model-page
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
---
# add-model-page
add, update, or remove model pages in the ComfyUI website.
## Trigger phrases
- `Add a model page for <model-name>`
- `Update the model page for <model-name>`
- `Remove <model-name> from model pages`
## Phase 1 — Parse the request
Extract:
- **action**: `add` | `update` | `remove`
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
Example: `flux1_dev.safetensors``flux1-dev`
## Architecture overview
Models come from two sources merged at build time:
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
To regenerate the JSON from workflow_templates:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
```
This writes `apps/website/src/config/generated-models.json` directly.
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
---
## Phase 2 — Gather model data (ADD / UPDATE)
Run the generator to get fresh data, then find the model:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
apps/website/src/config/generated-models.json
```
The JSON fields are:
- `slug` — URL slug
- `name` — exact filename or display name for partner nodes
- `huggingFaceUrl` — download URL (empty for partner nodes)
- `directory``diffusion_models` | `loras` | … | `partner_nodes`
- `workflowCount` — integer
- `displayName` — human-readable name
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
`generate-models.ts` and re-run. Otherwise tell the user.
---
## Phase 3 — Check for existing entry
```bash
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
apps/website/src/config/generated-models.json
```
- Match found + action is `add` → switch to UPDATE flow automatically
- No match + action is `update` → stop and tell the user
---
## Phase 4A — ADD: new partner/API model not in workflow_templates
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
`apps/website/scripts/generate-models.ts`:
```typescript
mymodel: { name: 'My Model', slug: 'my-model' },
```
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
in `generated-models.json` automatically.
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
```typescript
'my-model': {
docsUrl: 'https://docs.comfy.org/tutorials/...',
blogUrl: 'https://blog.comfy.org/...',
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
featured: true
}
```
No changes to `models.ts` or `translations.ts` are needed.
---
## Phase 4B — UPDATE: edit existing entry
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
featured). For `displayName` or `directory` changes, edit the entry directly in
`generated-models.json` (until the next generator run would overwrite it — then
fix the source in `generate-models.ts`).
---
## Phase 4C — REMOVE: delete entry
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
pointing to the replacement). No translation file changes needed.
---
## Phase 5 — Verify TypeScript
```bash
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
```
Fix any type errors before proceeding. Common issues:
- `ModelDirectory` type not matching a new `directory` value — add it to the union
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
---
## Phase 6 — Create PR
```bash
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
git checkout -b $BRANCH
git add apps/website/src/config/generated-models.json \
apps/website/scripts/generate-models.ts \
apps/website/src/config/model-metadata.ts
git commit -m "feat(models): add model page for MODEL-SLUG"
git push -u origin $BRANCH
gh pr create \
--title "Add model page: MODEL-SLUG" \
--body "$(cat <<'EOF'
Adds a new model page entry for MODEL-SLUG.
## Changes
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
EOF
)"
```
For UPDATE use branch `update-model-page-MODEL-SLUG`.
For REMOVE use `remove-model-page-MODEL-SLUG`.
---
## Error states
| Situation | Response |
| ------------------------------- | ---------------------------------------------------------------- |
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
| Slug already exists (add) | Switch to update flow automatically |
| Slug not found (update/remove) | Stop and ask user to confirm |
| Typecheck fails | Fix the error before pushing |

View File

@@ -9,18 +9,13 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## Quick Start
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
5. **Analyze**Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
5. **Execute**Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
6. **Verify** — After each wave, verify branch integrity before proceeding
7. **Log & Report** — Generate session report (`reference/logging.md`)
## System Context
@@ -112,35 +107,6 @@ Husky hooks fail in worktrees (can't find lint-staged config). Always use `git p
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
```bash
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
```
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
### Backport-Only Compatibility Shims
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
- `node.onXxx` callback assignments
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
- Public exports from `src/lib/litegraph/`
- Type changes affecting `litegraph-augmentation.d.ts`
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
@@ -181,26 +147,6 @@ Skip these without discussion:
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
### Path Pre-Filter (run BEFORE reading PR bodies)
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
| Path prefix | Bucket | Reason |
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
| `.github/workflows/` only | SKIP | CI/release infra |
| `packages/design-system/` only | SKIP | Design tokens, not core |
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
| `*.stories.ts` only | SKIP | Storybook only |
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
## Wave Verification
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:

View File

@@ -39,89 +39,6 @@ Check before backporting — these don't exist on older branches:
- **App builder** — check per branch
- **appModeStore.ts** — not on core/1.40
### Verify Target File Existence (Run Before Cherry-Pick)
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
```bash
# For each file the PR changes
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
echo "MISSING on $TARGET: $f"
fi
done
```
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
## Tiered Triage (Recommended for 30+ Candidates)
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
### Tier 1 — Core Editor Must-Haves
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
Indicators:
- `fix:` prefix and the bug is reproducible on the target branch
- Crash guards, runtime null checks, race-condition fixes
- Data-loss bugs (state not persisted, duplicates, drops)
- Security hardening (CSRF, XSS, auth)
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
- Subgraph correctness fixes
- Public-API extension callback fixes
Recommend `Y` to user.
### Tier 2 — Cloud-Distribution Only
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
Indicators:
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
- PR description mentions cloud staging issues
- Fix gated behind cloud feature flags
Default: ask the cloud release rotation owner. If unsure, defer.
### Tier 3 — Skip
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
Indicators:
- All changes in test files even if the PR touched `src/` test files
- Storybook stories only
- Lint config / lint rule additions
- Documentation comments
- Internal refactors with no behavior change
### Presentation Format
When showing tier results to the user, format as:
```text
Tier 1 (N PRs) — strong backport candidates
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
- #10849 fix: store promoted widget values per SubgraphNode instance
Why: Multiple instances overwriting each other's promoted widget values — data loss
Tier 2 (N PRs) — cloud-distribution release rotation should decide
- #11636 fix: enable Chrome password autofill on signup form
- ...
Tier 3 (N PRs) — skip recommended
- #11586 fix: website polish (apps/website/ only)
- ...
```
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
## Dep Refresh PRs
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.

View File

@@ -1,11 +1,5 @@
# Discovery — Candidate Collection
**Run all sources, then reconcile.** No single source is authoritative:
- Slack bot may flag PRs that have already been backported (false positive)
- Git gap may include PRs that don't need backport (test-only, design-system, website)
- Bot can also miss PRs that landed without the right labels
## Source 1: Slack Backport-Checker Bot
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
@@ -42,43 +36,7 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
gh pr view $PR --json files --jq '.files[].path'
```
## Source 4: Already-Backported PRs (cross-reference)
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
```bash
# Get all original PR numbers already backported to TARGET since the last release tag
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
| grep -oE "[0-9]+" \
| sort -un > /tmp/already-backported.txt
```
Subtract this list from your candidates.
## Reconciliation Workflow
```bash
# 1. Slack bot list (parse from export)
# /tmp/bot-flagged.txt — one PR# per line, sorted
# 2. Git gap fix/perf only
MB=$(git merge-base origin/main origin/$TARGET)
git log --format="%h|%s" $MB..origin/main \
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
| sort -un > /tmp/gap-fixes.txt
# 3. Already backported (Source 4 above)
# 4. Candidates = (gap-fixes bot-flagged) already-backported
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
```
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
## Output: candidate_list.md
Table per target branch:
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
| PR# | Title | Category | Flagged by Bot? | Decision |

View File

@@ -6,43 +6,6 @@
2. Medium gap next (quick win)
3. Largest gap last (main effort)
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
cd /tmp/dryrun-TARGET
CLEAN=()
CONFLICT=()
for pr in "${CANDIDATES[@]}"; do
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
if git cherry-pick -m 1 $SHA 2>/dev/null; then
CLEAN+=($pr)
else
CONFLICT+=($pr)
git cherry-pick --abort
fi
git checkout --detach HEAD 2>/dev/null
git branch -D dryrun-$pr 2>/dev/null
done
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
cd -
git worktree remove /tmp/dryrun-TARGET --force
```
Use the result to:
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
## Step 1: Label-Driven Automation (Batch)
```bash
@@ -125,39 +88,6 @@ for PR in ${CONFLICT_PRS[@]}; do
git add .
GIT_EDITOR=true git cherry-pick --continue
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
# If the conflict resolution touched any of these surfaces, consult oracle
# BEFORE pushing. A bad shim is worse than no fix:
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
# - Public exports from src/lib/litegraph/
# - Type changes in litegraph-augmentation.d.ts
# If a public callback's signature/contract changed: add a backport-only
# compatibility shim that preserves the OLD contract while keeping the
# new fix. Document it in the commit body under
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
# ───────────────────────────────────────────────────────────────────────
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
# Guard each targeted command against empty file lists — running `pnpm test:unit`
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
pnpm typecheck
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
if [ ${#TEST_FILES[@]} -gt 0 ]; then
pnpm test:unit "${TEST_FILES[@]}"
else
echo "No changed test files — skipping targeted unit tests"
fi
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
if [ ${#CODE_FILES[@]} -gt 0 ]; then
pnpm exec eslint "${CODE_FILES[@]}"
pnpm exec oxfmt --check "${CODE_FILES[@]}"
else
echo "No changed ts/vue files — skipping targeted lint/format"
fi
git push origin backport-$PR-to-TARGET --no-verify
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
@@ -313,9 +243,6 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
## CI Failure Triage
@@ -341,40 +268,3 @@ Common failure categories:
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
## PR Body Template (Manual Cherry-Picks)
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
```markdown
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
Cherry-picked from upstream merge commit `SHORT_SHA`.
## Why
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
## Conflict resolution
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
- [...]
## Backport-only compatibility fix (if applicable)
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
## Validation
- `pnpm typecheck`
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
- `pnpm exec eslint <changed files>` ✅ (0 errors)
- `pnpm exec oxfmt --check` ✅ (clean)
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
Original PR: #ORIG / Original commit: `FULL_SHA`
```
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"

View File

@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
```bash
# Vitest
pnpm test:unit <test-file>
pnpm test:unit -- <test-file>
# Playwright
pnpm test:browser:local -- --grep "<test name>"

View File

@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
```bash
# Instead of fixing the code, just updating the snapshot to match buggy output
pnpm test:unit --update
pnpm test:unit -- --update
```
If a snapshot needs updating, the fix should change the code behavior, not the expected output.

View File

@@ -114,7 +114,7 @@ await expect(async () => {
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
2. Extract and view trace: `npx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`

View File

@@ -2,7 +2,6 @@ issue_enrichment:
auto_enrich:
enabled: true
reviews:
profile: assertive
high_level_summary: false
request_changes_workflow: true
auto_review:
@@ -16,35 +15,19 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |
@@ -63,14 +46,3 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -1,19 +0,0 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

View File

@@ -29,5 +29,3 @@ runs:
if: ${{ inputs.include_build_step == 'true' }}
shell: bash
run: pnpm build
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'

View File

@@ -1,22 +1,21 @@
name: Upsert Comment Section
description: >
Manage a consolidated PR comment with independently-updatable sections.
Multiple CI workflows can share the same comment by using the same
comment-marker and different section-names. Each workflow upserts only
its own section, leaving other sections intact.
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
Valid section names: "e2e", "preview", "screenshot-update".
inputs:
pr-number:
description: PR number to comment on
required: true
section-name:
description: 'Section identifier (e.g. "playwright", "storybook", "e2e", "preview")'
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
required: true
section-content:
description: Markdown content for this section
required: true
comment-marker:
description: Top-level HTML comment marker shared by all sections in this comment
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
required: true
token:
description: GitHub token with pull-requests write permission
@@ -39,10 +38,6 @@ runs:
const sectionContent = process.env.INPUT_SECTION_CONTENT
const commentMarker = process.env.INPUT_COMMENT_MARKER
if (!/^[a-z0-9-]+$/.test(sectionName)) {
throw new Error(`Invalid section-name: ${sectionName}`)
}
const sectionStart = `<!-- section:${sectionName}:start -->`
const sectionEnd = `<!-- section:${sectionName}:end -->`
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`

View File

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

@@ -109,69 +109,3 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
-e '1x00000000000000000000AA' \
dist; then
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
exit 1
fi
echo '✅ No Turnstile sitekey references found'

View File

@@ -54,14 +54,10 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
- name: Upload perf metrics
if: always()

View File

@@ -20,8 +20,6 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -39,33 +37,31 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -85,18 +81,8 @@ jobs:
fi
done
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -105,7 +91,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -114,18 +100,20 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped \
--synthesize-missing
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -134,10 +122,7 @@ jobs:
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -38,15 +38,16 @@ jobs:
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Test Start — upsert playwright starting section
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: playwright
section-content: '## 🎭 Playwright: ⏳ Running...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
@@ -58,15 +59,13 @@ jobs:
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion — deploy and generate section
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
SUMMARY_FILE: playwright-section.md
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
run: |
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \
@@ -75,22 +74,5 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"$BRANCH_NAME" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
- name: Read playwright section
id: section
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('playwright-section.md') != ''
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: playwright-section.md
- name: Upsert playwright section into unified report
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: playwright
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}

View File

@@ -45,10 +45,12 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: pnpm build:cloud
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6
@@ -73,8 +75,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +95,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +152,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }}
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
@@ -226,7 +228,7 @@ jobs:
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting section into the unified PR report comment for non-forked PRs
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
@@ -242,16 +244,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Upsert playwright starting section into unified report
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: playwright
section-content: '## 🎭 Playwright: ⏳ Running...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting"
# Deploy and upsert final playwright section for non-forked PRs only
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
runs-on: ubuntu-latest
@@ -275,34 +278,15 @@ jobs:
pattern: playwright-report-*
path: reports
- name: Deploy reports and generate section
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
SUMMARY_FILE: playwright-section.md
BRANCH_NAME: ${{ github.head_ref }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"$BRANCH_NAME" \
"${{ github.head_ref }}" \
"completed"
- name: Read playwright section
id: section
if: ${{ !cancelled() && hashFiles('playwright-section.md') != '' }}
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: playwright-section.md
- name: Upsert playwright section into unified report
if: ${{ !cancelled() && steps.section.outputs.content != '' }}
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: playwright
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -38,15 +38,16 @@ jobs:
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Storybook Start — upsert storybook starting section
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: storybook
section-content: '## 🎨 Storybook: 🚧 Building...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
@@ -57,7 +58,7 @@ jobs:
name: storybook-static
path: storybook-static
- name: Handle Storybook Completion — deploy and generate section
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -65,28 +66,9 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
SUMMARY_FILE: storybook-section.md
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"$BRANCH_NAME" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
- name: Read storybook section
id: section
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('storybook-section.md') != ''
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: storybook-section.md
- name: Upsert storybook section into unified report
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: storybook
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}

View File

@@ -37,14 +37,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Upsert storybook starting section into unified report
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: storybook
section-content: '## 🎨 Storybook: 🚧 Building...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting"
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
@@ -95,7 +96,6 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
@@ -164,38 +164,19 @@ jobs:
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
- name: Deploy Storybook and generate section
- name: Deploy Storybook and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.storybook-build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.storybook-build.outputs.workflow-url }}
SUMMARY_FILE: storybook-section.md
BRANCH_NAME: ${{ github.head_ref }}
run: |
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"$BRANCH_NAME" \
"${{ github.head_ref }}" \
"completed"
- name: Read storybook section
id: section
if: hashFiles('storybook-section.md') != ''
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: storybook-section.md
- name: Upsert storybook section into unified report
if: steps.section.outputs.content != ''
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: storybook
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
@@ -227,17 +208,35 @@ jobs:
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Upsert Chromatic section into unified report
uses: ./.github/actions/upsert-comment-section
- name: Update comment with Chromatic URLs
uses: actions/github-script@v8
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: chromatic
section-content: |
### 🎨 Chromatic Visual Tests
- 📊 [View Chromatic Build](${{ needs.chromatic-deployment.outputs.chromatic-build-url }})
- 📚 [View Chromatic Storybook](${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }})
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
// Find the existing Storybook comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const storybookComment = comments.find(comment =>
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
);
if (storybookComment && buildUrl && storybookUrl) {
// Append Chromatic info to existing comment
const updatedBody = storybookComment.body.replace(
/---\n(.*)$/s,
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: storybookComment.id,
body: updatedBody
});
}

View File

@@ -4,9 +4,6 @@ name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- 'core/**'
- 'cloud/**'
paths:
- 'apps/website/**'
- 'packages/design-system/**'
@@ -30,7 +27,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -58,7 +55,6 @@ jobs:
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
@@ -152,20 +148,10 @@ jobs:
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -40,6 +40,3 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

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

View File

@@ -1,99 +0,0 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
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]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
else
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
fi
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: ${{ steps.allowlist.outputs.allowlist }}
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
name: Detect Unreviewed Merge
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
# tracking issues are filed in Comfy-Org/unreviewed-merges.
on:
push:
branches: [main, master]
concurrency:
group: detect-unreviewed-merge-${{ github.sha }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
jobs:
detect:
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
with:
approval-mode: latest-per-reviewer
secrets:
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}

View File

@@ -1,123 +0,0 @@
name: Model Page Discovery
on:
schedule:
- cron: '0 9 * * 1'
workflow_dispatch:
jobs:
discover:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Fetch model labels from hub API
id: hub
shell: bash
run: |
set -euo pipefail
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v6
with:
sparse-checkout: apps/website/src/config/generated-models.json
- name: Compare against existing models
id: compare
shell: bash
run: |
set -euo pipefail
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
EXISTING_SLUGS=$(node -e "
const fs = require('fs');
const models = JSON.parse(
fs.readFileSync(
'apps/website/src/config/generated-models.json',
'utf8'
)
);
console.log(JSON.stringify(models.map(m => m.slug)));
" 2>/dev/null || echo '[]')
ADDED_SLUGS=$(node -e "
const hub = $HUB_SLUGS;
const existing = new Set($EXISTING_SLUGS);
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
")
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
if [ "\$COUNT" -eq 0 ]; then
echo "No new models found."
else
echo "Found \$COUNT new model(s)"
fi
- name: Check for existing open discovery issue
id: existing_issue
if: steps.compare.outputs.new_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
COUNT=$(gh issue list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--search 'in:title "New models detected"' \
--json number \
--jq 'length')
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
- name: Open GitHub issue for new models
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count == '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
shell: bash
run: |
SLUG_LIST=$(node -e "
const slugs = $NEW_SLUGS;
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
")
gh issue create \
--repo "$GITHUB_REPOSITORY" \
--title "New models detected — add to model pages" \
--body "## $NEW_COUNT new model(s) found in hub
The weekly model discovery scan found model labels on the hub not yet in
\`apps/website/src/config/generated-models.json\`.
### New slugs ($NEW_COUNT)
$SLUG_LIST
### Next steps
1. Review which of these warrant an SEO model page
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
---
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
- name: Skip — open issue already exists
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count != '0'
run: echo "An open discovery issue already exists — skipping creation."
- name: No new models found
if: steps.compare.outputs.new_count == '0'
run: echo "No new models found — nothing to do."

View File

@@ -67,11 +67,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
# Persist a token with `workflow` scope so the backport push can
# include changes to .github/workflows/**. The default GITHUB_TOKEN
# is refused by GitHub when a push creates/updates workflow files,
# which silently aborted the whole job (see PR #12804 backport).
token: ${{ secrets.PR_GH_TOKEN }}
- name: Configure git
run: |
@@ -278,49 +273,32 @@ jobs:
continue
fi
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
git push origin "${BACKPORT_BRANCH}"
fi
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main || git checkout -f main
git checkout main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort || true
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
git checkout main
git branch -D "${BACKPORT_BRANCH}"
fi
echo "::endgroup::"
@@ -401,10 +379,6 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -437,37 +411,19 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "already-exists" ]; then
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -483,10 +439,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -1,55 +0,0 @@
# Description: Team-gated multi-model Cursor review — a thin caller for the
# reusable workflow in Comfy-Org/github-workflows, which is the single source of
# truth for the panel, judge, prompts, and scripts. Triggered by the
# 'cursor-review' label.
#
# Access control (team-only, two layers):
# 1. Only users with triage permission or higher can apply a label in a public
# repo, so the public cannot trigger this.
# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks
# get no secrets), so CURSOR_API_KEY is reachable only on internal branches.
name: 'PR: Cursor Review'
on:
pull_request:
types: [labeled, unlabeled]
permissions:
contents: read
pull-requests: write
concurrency:
# Re-labeling cancels an in-flight run for the same PR + label.
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
cancel-in-progress: true
jobs:
cursor-review:
if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review'
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
# paths (Playwright snapshots, generated manager types).
diff_excludes: >-
:!**/package-lock.json
:!**/yarn.lock
:!**/pnpm-lock.yaml
:!**/node_modules/**
:!**/.claude/**
:!**/dist/**
:!**/vendor/**
:!**/*.generated.*
:!**/*.min.js
:!**/*.min.css
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

View File

@@ -5,8 +5,6 @@ on:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
types:
- completed
branches-ignore:
- main
permissions:
contents: read
@@ -140,8 +138,6 @@ jobs:
const legacyMarkers = [
'<!-- COMFYUI_FRONTEND_SIZE -->',
'<!-- COMFYUI_FRONTEND_PERF -->',
'<!-- PLAYWRIGHT_TEST_STATUS -->',
'<!-- STORYBOOK_BUILD_STATUS -->',
];
const comments = await github.paginate(github.rest.issues.listComments, {
@@ -162,19 +158,11 @@ jobs:
}
}
- name: Read PR report
id: report
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./pr-report.md
- name: Upsert bundle/perf/coverage section into unified report
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/upsert-comment-section
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
section-name: ci-metrics
section-content: ${{ steps.report.outputs.content }}
report-file: ./pr-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,142 +0,0 @@
name: Publish Desktop Bridge Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.1.2)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
concurrency:
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_desktop_bridge_types:
name: Publish @comfyorg/comfyui-desktop-bridge-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
env:
REF: ${{ inputs.ref }}
DEFAULT_REF: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [ -z "$REF" ]; then
REF="$DEFAULT_REF"
fi
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Verify package
id: pkg
env:
INPUT_VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
if [ "$VERSION" != "$INPUT_VERSION" ]; then
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if version already on npm
id: check_npm
env:
NAME: ${{ steps.pkg.outputs.name }}
VER: ${{ steps.pkg.outputs.version }}
shell: bash
run: |
set -euo pipefail
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ inputs.dist_tag }}
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
working-directory: packages/comfyui-desktop-bridge-types

View File

@@ -23,10 +23,6 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -53,15 +49,12 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -110,7 +103,6 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

View File

@@ -59,7 +59,7 @@ jobs:
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
pnpm build
NX_SKIP_NX_CACHE=true pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
@@ -92,7 +92,9 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshots:
refresh-snapshot:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,39 +31,28 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
body: |
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-website-snapshots-${{ github.run_id }}
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available globally"
echo "TypeScript and Vue compiler already available locally"
fi
- name: Run Claude Documentation Review

5
.gitignore vendored
View File

@@ -19,7 +19,6 @@ yarn.lock
node_modules
.pnpm-store
.nx
dist
dist-ssr
*.local
@@ -90,6 +89,10 @@ storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*

View File

@@ -21,8 +21,7 @@ module.exports = defineConfig({
'ar',
'tr',
'pt-BR',
'fa',
'he'
'fa'
],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
@@ -38,11 +37,5 @@ module.exports = defineConfig({
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
IMPORTANT Hebrew Translation Guidelines:
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
`
})

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

2
.nvmrc
View File

@@ -1 +1 @@
25
24

1
.nxignore Normal file
View File

@@ -0,0 +1 @@
.claude/worktrees

View File

@@ -9,7 +9,6 @@
"packages/registry-types/src/comfyRegistryTypes.ts",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",
"**/__fixtures__/**/*.json",
"apps/website/src/content/**/*.mdx"
"**/__fixtures__/**/*.json"
]
}

View File

@@ -2,6 +2,7 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",
@@ -65,7 +66,6 @@
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unreachable": "error",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
@@ -74,30 +74,17 @@
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/valid-title": "off",
"vitest/require-to-throw-message": "off",
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/hoisted-apis-on-top": "error",
"typescript/no-misused-spread": "error",
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"it": "for",
"describe": "for",
"suite": "for"
}
],
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",

View File

@@ -78,21 +78,6 @@ const config: StorybookConfig = {
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/composables/billing/useBillingContext',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
@@ -41,6 +42,7 @@ setup((app) => {
}
}
})
app.use(ConfirmationService)
app.use(ToastService)
})

View File

@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
## Monorepo Architecture
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
The project uses **Nx** for build orchestration and task management
## Package Manager
@@ -179,9 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
23. Favor pure functions (especially testable ones)
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
26. Do not add alias helpers whose implementation is just a single-line call to another function
- Bad: `function id(value) { return nodeId(value) }`
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
## Design Standards
@@ -240,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records
@@ -249,7 +247,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
@@ -310,20 +308,6 @@ When referencing Comfy-Org repos:
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER add multi-line block comments to justify trivial code changes
- A one-line fix does not need a three-line comment explaining why
- A guard clause that mirrors another file does not need a comment naming that file
- A test setup line does not need a comment paraphrasing what the next line does
- If the diff is small and obvious, the comment is noise — write the code and move on
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
- **Penance protocol when you catch yourself adding one of these comments:**
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
- NEVER use the `dark:` tailwind variant
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`

View File

@@ -1,60 +1,95 @@
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Topbar
/src/components/topbar/ @pythongosssss
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-Org/comfy_frontend_devs
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
/src/components/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/imagecrop/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useImageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
# Painter
/src/extensions/core/painter.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @Comfy-Org/comfy_frontend_devs
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
# 3D
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3dLazy.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3d.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dDrag.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dViewer.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/services/load3dService.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
# Manager
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @Comfy-Org/comfy_frontend_devs
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987 @Comfy-Org/comfy_frontend_devs
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -1,730 +0,0 @@
# Git Branching and Release Strategy
Status: Proposed
Scope: ComfyUI_frontend branching, release management, and environment promotion
Audience: frontend engineers, release rotation, QA, cloud and core release stakeholders
This document does three things:
1. Maps the current branching and release process and its measured pain points.
2. Reviews a draft proposal to replace it with a 4-tier environment-branch model
(testing, dev, staging, main), including an honest scorecard and a
keep-or-reject disposition for every element of that proposal.
3. Specifies the recommended strategy, how it addresses each pain point, and a
phased rollout plan with risks and open questions.
## 1. Executive summary
The current process is not "only main". The repo runs one eternal development
branch plus 55 frozen release branches (38 core lines, core/1.6 through
core/1.47 with gaps; 17 cloud lines, cloud/1.31 through cloud/1.47), a
label-driven cherry-pick backport pipeline,
and a biweekly promotion train into ComfyUI core. The pain is real but it is
not caused by a missing environment hierarchy. It is caused by the distance
between main and the shipped lines: the longer a pinned release line lives,
the more cherry-picks it needs, the harder each one gets, and the bigger and
riskier each release batch becomes.
The draft 4-tier proposal is reviewed in section 4. Verdict: do not adopt as
written (overall 4/10 for this repo), because it models a single-track web
application while this product permanently ships three concurrent version
tracks, and its central promise (eliminating backports) is unachievable while
ComfyUI core pins an exact frontend package version. Eight of its underlying
instincts are correct and are adopted into the recommendation.
The recommended strategy (section 6) is: one eternal branch (main), short-lived
version branches only where a pinned version demands them (core/x.y), pipeline
promotion of build-once artifacts for cloud environments instead of environment
branches, hard freeze-as-code and drift limits, invariant checks instead of
notification bots, and a machine-maintained production pointer that gives git
visibility into what is deployed without human merge ceremony. This direction
aligns with and extends the org's existing shipping-speed initiative rather
than relitigating it.
## 2. Current topology: three concurrent tracks
Any branching strategy for this repo must first model what actually ships.
There is no single production. Three tracks run concurrently, each with its own
consumers, artifact, and patch path:
| Track | Consumers | Artifact | Deploys via | Patch path today |
| --- | --- | --- | --- | --- |
| Nightly | Community users running `--front-end-version @latest` (thousands) | GitHub release built from main | Nightly version-bump PR, tag, release | Fix merges to main, ships next nightly |
| Cloud | cloud.comfy.org users | Static assets built per commit SHA into GCS by the cloud repo | testcloud tracks the active `cloud/x.y` tip; staging and prod promote a SHA pointer via overlays and ArgoCD | Cherry-pick to `cloud/x.y` via backport label, then staged deploy |
| Core GA / desktop | ComfyUI stable and desktop installs | `comfyui-frontend-package` wheel on PyPI, pinned exactly in ComfyUI `requirements.txt` | Biweekly train: patch bump on `core/x.y`, PyPI publish, pin-bump PR on ComfyUI | Cherry-pick to `core/x.y`, patch release, new pin PR |
As of mid July 2026: main is at 1.48.x, cloud runs the 1.47 line, and the
ComfyUI pin is still on 1.45.x. Three minors of distance between main and core
GA is the normal operating state, not an anomaly.
```mermaid
flowchart LR
FIX[Fix merges to main] --> NB[Nightly bump and tag] --> NU[Nightly users]
FIX --> CLB[Cherry-pick onto cloud line] --> STG[Staging deploy] --> PRD[Prod deploy] --> CU[Cloud users]
FIX --> COB[Cherry-pick onto core line] --> PYP[PyPI patch release] --> PIN[ComfyUI pin update] --> DU[Core and desktop users]
```
Key mechanics worth naming because the strategy must preserve or deliberately
replace each one:
- Every minor bump on main automatically freezes the previous minor into paired
`core/x.y` and `cloud/x.y` branches and rotates the matching backport labels.
- Backports are label-driven: `needs-backport` plus a target label triggers an
automated cherry-pick PR; conflicts fall back to a documented manual path.
- The cloud deploy already promotes build-once artifacts: assets are built one
time per SHA, and staging and prod move by repointing that SHA. Rollback is a
pointer revert with no rebuild.
- Core GA promotion is a biweekly scheduled train that publishes to PyPI and
drafts the ComfyUI pin-bump PR.
- A rotating release owner (the "sheriff") drives promotions; per-PR backport
ownership was deliberately moved to feature pods in June 2026.
- Frontend builds destined for core GA soak in the core nightly channel
(about two weeks for graph-touching changes) because there is no automated
signal when a change breaks a community custom node. The soak is a
compensating control for missing telemetry, not a property of safe code.
These mechanics are documented operationally in `docs/release-process.md`,
which remains the runbook of record for day-to-day releases. This document
governs the target strategy, and that runbook gets updated as each phase in
section 8 lands.
## 3. Pain points of the current process
Numbered for traceability to section 7. All are observed, not hypothetical.
- **PP1. Drift compounds on long-lived lines.** The 1.45 line spans 69 days
from its minor cut to its latest patch and is still the pinned line: 19
patch releases, 61 commits landed on it after it froze (54 backport
cherry-picks plus 7 patch-release bumps), and main-vs-stable divergence of
428 PRs by the latest patch. Backports onto old lines increasingly do not
apply cleanly, and some do not even work once applied because the
surrounding code diverged (a fix was backported to a GA line where it could
not function without a second dependency PR, and was ultimately abandoned
as a known issue).
- **PP2. Per-PR cherry-picks can silently miss a target.** A fix that needed
two cloud lines landed on only one; the next release shipped the regression
back to users. Three independent safeguards (notification bot, sweep check,
PR comment) all failed to catch it (FE-713).
- **PP3. Release batches grow superlinearly risky.** Missing one train window
meant a double-minor release: 401 PRs validated in a single QA pass, the
largest surface the team has ever had to certify, including high-risk
subsystem rewrites.
- **PP4. The backport tooling itself fails silently.** The notification bot was
down for six weeks without anyone noticing; the repo-level auto-merge setting
is off, which silently turned the backport auto-merge flag into a no-op and
required a workaround workflow; the manual retry path of the backport
workflow was broken (FE-1282).
- **PP5. Freeze state lives in conversation, not tooling.** A verbally frozen
line was bumped past by an engineer who reasonably read stalled automation as
a missed dispatch. The resulting premature minor cut cost one to two weeks of
recovery. The post-mortem's first action item: encode freeze state durably.
- **PP6. Human bottlenecks and burnout.** One engineer ran the release rotation
for roughly two months straight; urgent core releases have required chasing
approvals late at night. Backport PRs add review friction (authors cannot
always self-approve, approvers are asleep, label permissions vary).
- **PP7. Latency invites bypasses.** A simple graph change takes a minimum of
about 16 days to reach core GA (review, two-week soak, release), up to 27 if
it just misses a window. Under deadline pressure a customer demo was shipped
via a one-off deployment that routed around the release process entirely,
creating an unowned production surface.
- **PP8. Release state is hard to read.** Answering "what exactly is on cloud
prod" requires cross-referencing a branch tip, a deploy tag, and a SHA in
another repo's values file. Public release surfaces (GitHub releases, docs
changelog, in-app update notice) have drifted out of sync.
- **PP9. QA involvement is ad hoc.** Test plans are hand-built per release
(one release needed a bespoke plan naming 84 high-risk PRs); there is no
standing definition of entry and exit criteria per promotion gate.
- **PP10. Everything above burns cross-team trust.** Slow stable releases are
a recurring source of cross-team friction, and the release rotation absorbs
that pressure personally.
## 4. Review of the draft 4-tier proposal
### 4.1 The proposed model
The draft proposes four long-lived branches, each auto-deploying to its own
standing environment, promoted wholesale (no cherry-picks) by a release
manager, with hotfixes cut from main re-entering through staging, and a
main-to-testing back-merge closing each cycle:
```mermaid
flowchart LR
T[testing branch and env] -->|release manager merge| D[dev branch and env]
D -->|release manager merge| S[staging branch and env]
S -->|release manager merge| M[main branch equals prod]
M -.->|back-merge at cycle end| T
M -->|urgent defect| H[hotfix branch] --> S
```
Claimed properties: engineers merge freely into testing; dev is a guaranteed
stable baseline; staging mirrors production for UAT; main is 1-to-1 with what
is deployed, "eliminating the complicated and error-prone process of
back-porting entirely."
This model was reviewed against the org context in sections 2 and 3, the
industry evidence in section 5, and an adversarial defense pass (a reviewer
whose explicit job was to defend the proposal and refute the critique; several
initial critique points were withdrawn or narrowed as a result and are noted
below).
### 4.2 What the proposal gets right
These instincts are correct, and each is adopted in section 6 via a cheaper
mechanism than a branch tier:
- **K1. Deployed state should be inspectable in git.** Today it is not (PP8).
Adopted as a machine-maintained prod pointer (6.5).
- **K2. Freezes must be enforced by tooling, not by verbal agreement.** The
premature-bump incident (PP5) proves it. Adopted as freeze-as-code (6.6).
- **K3. Promotions need named owners and explicit gates.** Adopted via GitHub
Environments protection rules and the existing rotation (6.4).
- **K4. Engineers need a guaranteed-good base to branch from when trunk is
red.** Adopted as last-green stable tags on main (6.7).
- **K5. Fix propagation needs a hard discipline.** The draft's down-merge SLA
becomes the inverse and industry-standard rule: upstream first, fix lands on
main before any release line (6.3).
- **K6. Whole-unit promotion beats per-PR picking wherever a track allows it.**
Wholesale promotion makes the PP2 failure class (a missed per-PR pick)
unrepresentable. Adopted for the cloud track as whole-artifact promotion,
and bounded on the core track by a drift SLO (6.8).
- **K7. Every promotion should auto-deploy its surface.** Directionally right
and already sanctioned by in-flight work that removes no-value manual steps
(FE-1176). Adopted throughout.
- **K8. QA deserves a first-class, named slot in the release path.** The only
document in this debate that gives QA an explicit stage. Adopted as defined
entry/exit criteria per gate instead of a dedicated environment (6.4).
### 4.3 Findings
Ordered by severity. "Withdrawn" notes mark initial critiques that did not
survive the adversarial defense, kept here so the review is honest about its
own error bars.
**Fatal (any one of these blocks adoption as written):**
- **F1. The central claim is false for this product.** "Eliminates
back-porting entirely" cannot hold while ComfyUI core pins an exact
`comfyui-frontend-package` version and desktop users run pinned installs.
A linear four-tier chain holds exactly one version in flight; it has no
mechanism to patch a shipped 1.45 while 1.47 is mid-promotion and 1.48 is
on nightly. Fixes to pinned lines remain cherry-picks plus PyPI patch
releases, that is, backports. The proposal eliminates the word by omitting
the surface that needs it: nowhere does it mention the pin, PyPI, desktop,
the soak, or custom nodes. (Narrowed but confirmed under defense: wholesale
trains do reduce drift-driven backport volume; they do not remove the
pinned-version axis.)
- **F2. It re-models an existing artifact pipeline as merge ceremony, and
regresses it.** Cloud promotion is already build-once: one SHA-keyed asset
build, promoted by pointer, rollback with no rebuild. Branch-tier CD means
each tier builds its own merge commit, so the artifact validated on staging
is provably not the artifact deployed from main. Worse, GitHub pull request
merges never fast-forward, so gated promotion PRs mint a new SHA at every
tier: "human-gated promotion" and "main is SHA-identical to prod" are
mutually exclusive with native GitHub mechanics. One of the model's two
core promises must break.
- **F3. The soak has no home.** The two-week custom-node soak is the org's
central regression control for its worst historical failure class. In a
linear chain it either occupies the staging tier permanently (capping all
release cadence at soak length and colliding with the hotfix path) or
silently disappears while its automated replacement remains unstaffed.
- **F4. It rows against the org's sanctioned direction.** The shipping-speed
initiative targets PR-to-prod under 48 hours, backport rate under 10
percent, and stable tags cut from main every 24 to 48 hours, backed by an
internal PRD and in-flight Linear work (FE-1176, FE-602/BE-800, release
gate automation). The draft adds an N-day merge freeze (at roughly 330
merged PRs per month across all targets, about 230 of them on main, that
is a real stall), three human promotion gates,
and full-cycle latency for every change. DORA's trunk research lists "no
code freezes" as a success criterion for elite delivery.
**Major:**
- **F5. The hotfix path is inoperable mid-cycle.** Hotfixes cut from main
re-enter through staging. Whenever staging holds next-cycle content in UAT
(most of the calendar), an urgent prod fix either waits out UAT or drags
unreleased work to production. At the observed fix rate on live lines
(54 backports in the 53 days after the 1.45 line froze, about one per
day), "restart UAT on every hotfix" is a validation livelock. Notably, both parents of this model do it differently:
GitLab Flow is strictly upstream-first, and GitFlow merges hotfixes to
master directly. The draft inherits the weaker property of each.
- **F6. "Guaranteed stable dev" is a smoke test, not a guarantee.** dev
receives testing wholesale, so at promotion time dev is byte-identical to
testing at freeze. The only stability delta is whatever a quick smoke pass
catches, and the tier's only unique content comes from direct-to-dev
hotfixes, which is the environment-drift anti-pattern. A second standing
environment running identical code adds configuration-drift false positives,
not signal. (Narrowed under defense: as a lagged checkpoint, dev does have
value mid-cycle; but a last-green tag delivers the same checkpoint without
a branch, an environment, or a gate.)
- **F7. Every gate is human and every suite is undefined.** The model's
stability claims rest entirely on "smoke tests," "all regression and
acceptance tests," and release-manager judgment. No suite, owner, or pass
criterion is named anywhere. Against roughly 330 PRs per month with a
two-person QA function, undefined manual gates become either the bottleneck
or a rubber stamp; the 401-PR pass (PP3) becomes the steady state, since
biweekly wholesale promotion at current velocity is roughly 110 to 165 PRs
per batch (main-line content alone runs about 110 per two weeks).
- **F8. No rollback story, and env-branch rollback poisons future
promotions.** The draft never mentions rollback. Reverting a bad promotion
merge makes git treat that content as already-merged, silently dropping it
from the next wholesale promotion until someone reverts the revert. And the
moment a prod deploy fails after the staging-to-main merge, main is ahead of
prod again, the exact state the model claims to abolish.
- **F9. The four auto-deploy pipelines do not exist and cannot be driven from
this repo.** This repo never deploys anything; it fire-and-forgets a
dispatch to the cloud repo, which owns builds, secrets, and ArgoCD. Standing
up per-branch CD means building four cross-repo deploy paths while the
backend concurrently moves to per-service delivery with its own promotion
model. The proposal is also purely additive: since it cannot serve the
pinned-package axis (F1), all existing release machinery keeps running
beside it, and the net delta is plus three eternal branches, plus one or two
standing environments, plus three human gates.
**Spec gaps and smaller issues:**
- **F10. Wholesale-minus-exceptions needs a written procedure.** Excluding one
bad PR from a promotion is revert-then-reland (and revert-the-revert later),
a known git footgun that will otherwise be improvised for the first time
during a release emergency. The draft's own exception clause re-authorizes
the cherry-picking it bans. (Initial claim "this is not a git operation" was
withdrawn: revert/reland is standard practice; the finding is that the
procedure is unwritten and its failure mode, silently dropped features, has
no detecting check.)
- **F11. Freeze semantics are underspecified rather than contradictory.**
(Initial "rules 1.c and 1.d contradict" was withdrawn.) The real gap:
stabilization fixes must merge during the freeze, so the freeze is porous by
design, with no tooling-enforced definition of what may enter, which is
exactly the un-encoded-freeze failure mode of PP5.
- **F12. Naming and contract frictions.** A tier named dev that is more stable
than testing inverts every industry convention; repointing main's semantics
would break the nightly channel contract, though the model does not actually
require the top tier to be named main (withdrawn as a fatal objection: tier
names are free variables; kept as a migration note). Rule 6.a's ancestry
policy has no native GitHub primitive and needs a small custom status check;
cheap, but this org's post-mortems document exactly this class of bespoke
automation failing silently (PP4).
### 4.4 Scorecard
| Dimension | Score | Basis |
| --- | --- | --- |
| Internal coherence | 5/10 | F5, F8, F10, F11 are real; several initial coherence critiques were withdrawn under defense |
| Fit to this product's topology | 3/10 | F1, F3: the pinned-package axis and the soak are unmodeled |
| Operability on GitHub with current infra | 2/10 | F2 SHA exclusivity, F9 pipelines do not exist, additive process surface |
| Alignment with current industry evidence | 3/10 | Section 5; env-branch promotion is a documented anti-pattern for continuously deployed surfaces, with narrow exceptions that map to pipeline approval gates |
| Quality of underlying instincts | 8/10 | K1 through K8 are correct and adopted |
| **Overall, as written, for this repo** | **4/10** | Fatal findings F1 through F4 |
For fairness: as a generic process for a single-track web application in the
era it comes from, this model rates roughly 6.5/10; GitLab Flow's environment
branch variant, which it closely resembles, remains documented practice. The
low score here is about fit to this product, not about the model's pedigree.
### 4.5 Disposition of each proposal element
| Proposal element | Disposition | Why |
| --- | --- | --- |
| Four long-lived environment branches | Reject | F1, F2, F3, F9; environments become pipeline stages instead |
| Wholesale promotion, no cherry-picks | Adapt | Correct for cloud as whole-artifact promotion (K6); impossible for the pinned core axis (F1) |
| Release-manager gated merges at three tiers | Adapt | One named human gate per surface via GitHub Environments required reviewers; no new role hierarchy (PP6) |
| N-day code freeze on the integration branch | Reject | F4; freezes are the documented anti-pattern and main never freezes today |
| Anything in testing ships in the current cycle | Adapt | Correct WIP-limit instinct; becomes the enforceable drift SLO in 6.8 |
| dev tier as stable baseline | Replace | Last-green stable tags on main (K4, F6) |
| staging tier for UAT and alpha access | Keep the capability | Already exists as the staging environment plus auth-gated and per-PR preview deploys; formalize QA criteria (K8) |
| main equals prod, 1-to-1 | Replace | Machine-maintained prod pointer ref (K1); merge-based parity is unenforceable on GitHub (F2) |
| Hotfix via branch from main through staging | Reject | F5; hotfix path per track defined in 6.9 |
| Back-merge main to testing at cycle end | Reject | Unnecessary once there is a single eternal branch; upstream-first makes down-merges structural (K5) |
| GitHub-enforced containment policy (6.a) | Adopt the idea | As a 20-line ancestry status check plus release-content verification (6.8), with heartbeat alerts given PP4 |
| Version tag on every prod deploy | Already exists | Cloud deploy tags and nightly tags; kept and unified in the prod pointer spec |
## 5. What the industry does
Genealogy first, because the draft cites a remembered decade-old post with
detailed charts. No post titled "a better git branching strategy" from that
era appears to exist. The remembered title and charts almost certainly belong
to Vincent Driessen's "A successful Git branching model" (nvie.com, 2010, the
GitFlow post). The remembered content, four auto-deploying environment tiers
promoted by wholesale merge, matches GitLab Flow's environment-branches
variant (2014). The hotfix-from-prod and back-merge mechanics are GitFlow's.
The draft is therefore a hybrid of two models from 2010 to 2014, both of whose
authors have since published significant caveats:
- Driessen added a note to the GitFlow post in 2020: teams shipping
continuously delivered web software should use a much simpler flow such as
GitHub Flow; GitFlow-style models remain reasonable for explicitly versioned
software with multiple versions in the wild. Both halves apply here, because
this product is both.
- GitLab's own docs now describe the environment-branch variant alongside a
release-branch variant with an explicit upstream-first rule (fix on main,
cherry-pick down), the same policy Google and Red Hat practice.
The current consensus, by product shape:
- **Continuously deployed services** (GitHub, Shopify, Google, the DORA
research corpus): one mainline, short-lived topic branches, merge queue,
feature flags, canary or percentage rollout. Environments are deployment
pipeline stages or GitOps folders, not branches. DORA's trunk criteria:
three or fewer active branches, daily merges, no code freezes; elite
performers correlate strongly with this shape. Environment-branch promotion
is repeatedly documented as an anti-pattern (Fowler's branching patterns;
the GitOps literature: merge-order skew, unintended config riding along,
undocumented prod drift from direct-to-branch hotfixes, per-branch rebuilds
violating build-once). The one conceded exception, regulated sign-off audit
trails, is satisfied by pipeline approval gates with deployment history.
- **Versioned or embedded software** (Chrome, Firefox, Microsoft Release
Flow): trunk plus short-lived per-release branches, cut just in time,
fix-only, upstream-first cherry-picks with approval, never merged back,
retired when the version leaves support. Chrome ships a milestone every four
weeks this way; Firefox calls the cherry-pick an uplift and gates it on
release management approval. The heavyweight tooling both maintain is
evidence that backports are a cost to be minimized, not a routine channel.
- **Hybrid products like this one** split the axes: trunk plus pipeline
promotion and flags for the continuously deployed surface; trunk plus
short-lived release branches for the pinned, versioned surface. That is
Microsoft Release Flow on one side and Chrome-style trains on the other,
sharing one trunk.
Sources: nvie.com/posts/a-successful-git-branching-model,
about.gitlab.com/topics/version-control/what-is-gitlab-flow,
dora.dev/capabilities/trunk-based-development,
martinfowler.com/articles/branching-patterns.html,
trunkbaseddevelopment.com/branch-for-release,
octopus.com/blog/stop-using-branches-deploying-different-gitops-environments,
beyond.minimumcd.org/docs/reference/practices/immutable-artifacts,
devblogs.microsoft.com/devops/release-flow-how-we-do-branching-on-the-vsts-team,
chromium.googlesource.com/chromium/src/+/master/docs/process/release_cycle.md,
wiki.mozilla.org/Release_Management/Release_Process,
github.blog/engineering/engineering-principles/deploying-branches-to-github-com,
shopify.engineering/successfully-merging-work-1000-developers.
## 6. Recommended strategy
### 6.1 Principles
- **P1. One eternal branch.** main is the single integration branch and the
nightly channel. Its semantics are a locked contract: nightly releases,
npm type publishing, cloud build dispatch, CI triggers, and community
tooling all key off it.
- **P2. Build once, promote artifacts.** Every merge to main produces one
SHA-keyed artifact. Environments receive that artifact by pointer; nothing
is ever rebuilt per environment. The artifact that passes staging and canary
is byte-identical to the artifact in production.
- **P3. Upstream first, always.** Every fix lands on main first, with tests.
Release lines receive changes only via the backport pipeline. Direct commits
to `core/*` and `cloud/*` are blocked for humans (release automation
excepted). This is the rule that prevents the PP2 class.
- **P4. Branches only where a version demands one.** A long-lived branch
exists only to serve a pinned, shipped version (`core/x.y` while ComfyUI
pins it). No branch exists to represent an environment.
- **P5. Invariants over notifications.** Every policy in this document that
matters is enforced by a required check or a reconciling audit with
heartbeat alerting, not by a bot that posts a message. PP4 is the reason.
- **P6. Gates are automation-first, with at most one named human approval per
surface,** held by the existing release rotation. No new role hierarchy.
- **P7. Small batches on a fixed cadence.** The unit of release stays as small
as the gates allow. The 401-PR batch is the documented anti-goal.
### 6.2 Branch roles
| Branch | Lifetime | Purpose | Who writes to it |
| --- | --- | --- | --- |
| `main` | Eternal | Integration, nightly channel, source of all builds | Engineers via peer-approved PRs |
| `core/x.y` | Weeks; dies when the ComfyUI pin moves off x.y | Serve the pinned PyPI line | Backport automation and release bumps only |
| `cloud/x.y` | Interim only; retired per 6.10 | Cloud release train until cloud CD lands | Backport automation only |
| `feature/*`, `fix/*` | Days | Topic branches off main (or a last-green tag when main is red) | The author |
| Prod pointer ref (`deployed/cloud-prod`) | Eternal, machine-written | Mirrors the verified deployed SHA after every prod sync, rollbacks included | Deploy pipeline only |
Target state, Phase 2 and later (until the cloud branch retirement in 6.10
lands, the cloud leg still promotes from the interim cloud/x.y line):
```mermaid
flowchart LR
ENG[Engineer merges PR to main] --> BUILD[One artifact built per SHA]
BUILD --> NIGHT[Nightly tag and release]
BUILD --> TC[testcloud] --> SC[staging] --> CAN[Canary percent rollout] --> PROD[Cloud prod]
PROD --> PTR[Prod pointer ref advances]
ENG --> GATE[Overnight release gate CI] --> TAGS[Last-green stable tag]
TAGS -->|next minor cut| LINE[core release line, backports only] --> TRAIN[Biweekly core train: patch bump, PyPI, pin PR]
```
### 6.3 The two release axes, explicitly separated
- **Cloud axis (continuous):** main to testcloud to staging to prod is
artifact promotion through pipeline stages. Gates live in the pipeline: the
overnight release-gate suite for candidate selection, a staging smoke
checklist with named QA criteria, and canary metrics with automatic
rollback once the canary program lands. Incomplete features land on main
dark behind feature flags so the trunk stays releasable while work is in
progress. No environment branches.
- **Core axis (versioned):** `core/x.y` branches exist because an exact
version is pinned by another product and shipped to desktops. They are cut
automatically (as today; in the target state the cut is taken from the
newest last-green tag rather than the raw pre-bump commit), receive fixes
only via upstream-first backports, and are retired when the pin moves. The fix for PP1 is not a new topology;
it is shortening how long these lines live and how far they drift (6.8).
### 6.4 Promotion gates
Implemented as GitHub Environments deployment protection rules (required
reviewers plus deployment history), not as branch merges. This natively
provides the audit trail that is the one legitimate case for human-gated
promotion, and it is the same primitive the draft's release-manager gates
actually wanted.
| Gate | Trigger | Automated criteria | Human |
| --- | --- | --- | --- |
| PR into main | Every PR | Unit, component, lint, typecheck | One peer approval |
| Release-gate verdict | Nightly | Behavioral suite, critical-path browser tests, custom-node harness when live | None; red verdict is an incident |
| Staging promotion | On green candidate | Artifact exists, gate verdict green | None (auto) |
| Prod promotion | Sheriff action | Staging smoke checklist green | One: rotation owner |
| Core GA train | Biweekly schedule | Line is green, soak or harness criteria met | One: pin PR merge in ComfyUI |
QA's slot (K8): the staging smoke checklist and the release regression scope
are standing documents with named QA owners and defined entry and exit
criteria, replacing hand-built per-release plans (PP9).
### 6.5 Production visibility (adopts K1)
A machine-maintained ref, `deployed/cloud-prod`, is written by the deploy
pipeline only after the production sync reports healthy, and it mirrors the
deployed SHA in both directions: a verified rollback moves the ref backward
to the rolled-back SHA (a recorded, non-fast-forward move with the rollback
reason logged, which the reconciler treats as healthy), so the ref never lies
about production during an incident. A reconciling check compares the ref against the cloud repo's
deployed SHA on every deploy and alerts on mismatch (P5). Answering "what is on prod" becomes
`git log deployed/cloud-prod`, which is the legitimate requirement behind the
draft's main-equals-prod rule, delivered without merge ceremony and immune to
the F2 impossibility.
### 6.6 Freeze-as-code (adopts K2)
Freeze state is a first-class marker (a repo variable or protected file) that
release automation checks before acting: the minor-bump dispatch refuses to
cut past a frozen line, and backport targeting warns on frozen targets. All
release bots carry heartbeat alerts; silence is an incident. These are the
premature-bump post-mortem action items, promoted into the strategy.
### 6.7 Stable base for engineers (adopts K4)
The nightly release-gate verdict stamps an immutable dated tag (for example
`stable/2026-07-18`) on the newest commit that passed the full gate and
advances a `stable/latest-green` branch-style ref to it. A moving ref, not a
moving tag, on purpose: git clients do not force-update moved tags by
default, so a moving tag would silently go stale locally. When main is red,
engineers branch from the ref or the dated tag instead of a standing dev
branch. Red main is itself an incident
with a named owner, which is what actually keeps the trunk usable.
### 6.8 Drift limits and content verification (adopts K6 and the containment idea)
- **Drift SLO:** the pinned release line being older than 28 days, or the pin
sitting more than two minors behind main, pages the rotation owner and
forces a train decision. This is the WIP limit that the draft expressed as
"anything in testing ships this cycle," made enforceable. PP1's 69-day line
becomes structurally impossible to reach silently. A breach also blocks the
next minor cut until acknowledged. The SLO activates with a burn-in
exemption for the already-breached 1.45 line, and a breach that traces to
an unmerged ComfyUI pin PR escalates per risk R2 instead of paging the
rotation, because that lever is not frontend-side.
- **Convergence invariant:** a scheduled check asserts every commit on a live
release line is an ancestor of main or arrived via the backport pipeline,
and that every prod-deployed SHA is reachable from a release line. Roughly
20 lines of CI; catches what three notification bots missed (PP2).
- **Release-content verification:** before a train departs, an automated
check confirms every PR labeled for that line actually landed on it, and
posts the diff of intent versus content. FE-713 becomes a failing check
instead of a user report.
### 6.9 Hotfix and rollback runbooks
Interim topology (while cloud/x.y lines exist; after the 6.10 retirement the
cloud leg promotes last-green artifacts from main instead):
```mermaid
flowchart TD
Q1{Where is the defect live?} -->|Nightly only| A1[Fix on main, ships next nightly]
Q1 -->|Cloud prod| A2[Fix on main with tests] --> A3[Backport label to the live cloud line] --> A4[Staging smoke on the patched artifact] --> A5[Prod promotion by rotation owner]
Q1 -->|Core GA or desktop| A6[Fix on main with tests] --> A7[Backport label to the pinned core line] --> A8[Patch release to PyPI] --> A9[Pin bump PR on ComfyUI]
Q1 -->|Deploy itself is bad, code is fine| R1[Rollback: repoint prod to previous SHA, no rebuild]
```
Rules: hotfixes never restart full UAT and never bundle unreleased content;
validation scope is the affected area plus the standing smoke checklist. The
sanctioned path must stay hours-scale (target: under 8 working hours from
fix merged on main to prod promotion), because the org has already
demonstrated that a slower sanctioned path manufactures shadow deploy surfaces
(PP7). Rollback is always a pointer move, never a git revert of a promotion;
revert is reserved for code defects on main with an explicit reland step.
### 6.10 Cloud branch retirement (the end state for the cloud axis)
`cloud/x.y` branches exist today because cloud deploys from a frozen line.
Once the release-gate suite plus canary auto-rollback are proven (several
clean ramps and at least one real auto-rollback in production), cloud promotes
last-green artifacts from main directly and the cloud branch family retires.
The cutover checklist must cover: release-branch creation stops cutting
`cloud/<minor>` and rotating cloud labels, the cloud deploy tag workflow
retires, build dispatch keys on main SHAs and promotion events, and testcloud
repoints from the cloud line tip to last-green tags. Until then, cloud/x.y
continues exactly as today; this document changes nothing about it yet.
## 7. How this solves the current pain points
| Pain point | Mechanism in this strategy | Status |
| --- | --- | --- |
| PP1 drift on long lines | Drift SLO (6.8) caps line age; shorter trains; upstream-first keeps lines fix-only | Mitigated; fully solved only if ComfyUI adopts pin bumps at train cadence (risk R2) |
| PP2 missed per-PR backports | Release-content verification plus convergence invariant (6.8); whole-artifact promotion on cloud removes per-PR picks there entirely | Solved: becomes a failing check, and unrepresentable on the cloud axis post 6.10 |
| PP3 giant QA batches | Fixed cadence with drift SLO forces small trains (P7); release-gate CI carries per-merge burden | Mitigated; batch size cannot silently grow past the SLO, and drops under 100 if the 13.1 decision moves the core train to weekly |
| PP4 silent tooling failure | Invariants and reconciling audits with heartbeat alerts replace notification-only bots (P5); prerequisite fixes named in 8 | Mitigated; bots can still break, but silence itself now alarms |
| PP5 verbal freezes | Freeze-as-code guards the bump and backport paths (6.6) | Solved: the premature-bump incident becomes mechanically impossible |
| PP6 human bottleneck | One human gate per surface on the existing rotation; merge-on-green everywhere else; backport approval rules simplified by making automation the only writer to release lines | Mitigated; the rotation remains, its late-night surface shrinks |
| PP7 latency invites bypasses | Hours-scale sanctioned hotfix lane (6.9); canary replaces calendar soak for cloud; core latency shrinks as the harness replaces the soak | Partially solved now, fully contingent on the custom-node harness (risk R1) |
| PP8 unreadable release state | Prod pointer ref (6.5); unified tags; one strategy document | Solved |
| PP9 ad hoc QA | Standing gate criteria with named QA owners (6.4) | Solved by process definition |
| PP10 cross-team trust | Publish the delivery metrics (9) on a fixed cadence so progress is legible instead of argued | Mitigated; trust follows the numbers |
The honest line on backports: this strategy does not eliminate them, because
nothing can while another product pins an exact version. It makes them rare
(drift SLO plus faster trains), safe (upstream-first plus content
verification), and boring (automation is the only writer to release lines).
The draft's promise was elimination; the achievable promise is a backport
rate under 10 percent with no silent misses.
## 8. Rollout plan
Prerequisites (fix before anything else changes): repair the manual backport
retry workflow (FE-1282), turn on the repo auto-merge setting and retire the
cron workaround, add heartbeat alerts to every release bot, and correct the
stale PyPI-attribution in docs/release-process.md so the runbook of record
matches the actual workflows.
- **Phase 0, immediately:** adopt this document; freeze-as-code; prod pointer
ref; drift SLO alerting; convergence invariant check; standing QA gate
criteria drafted (their operational acceptance is P1 item 9). No branch
topology changes at all.
- **Phase 1, with the release gate:** overnight release-gate suite produces
last-green tags; stable tags become the engineer base and the cloud
candidate source; staging promotion goes automatic on green.
- **Phase 2, with the canary:** percentage rollout with metric-gated
auto-ramp and auto-rollback on cloud prod; hotfix lane switches to
canary-validated promotion.
- **Phase 3, retirement:** cloud/x.y family retires per 6.10. Core/x.y
remains, on shorter trains, as long as ComfyUI pins exact versions; the
calendar soak retires only when the custom-node harness is live, staffed,
and has held the escaped-regression guardrail flat for two full cycles.
## 9. Goals and success metrics
| Goal | Metric | Target | Horizon |
| --- | --- | --- | --- |
| Ship fast | PR merge to cloud prod | Under 48 hours median | Phase 2 |
| Backports rare | Backported PRs / merged PRs | Under 10 percent | Phase 2 |
| Lines stay young | Max live release-line age | Under 28 days | Phase 1 |
| No silent misses | Escaped regressions from missed backports | Zero | Phase 0 |
| Batches stay small | PRs per QA-certified release | Under 100 | Phase 2, after the 13.1 cadence decision |
| Prod is legible | Time to answer "what is on prod" | One git command | Phase 0 |
| No shadow deploys | Unowned one-off production surfaces | Zero new | Phase 0 |
Leading indicators are the first three; report all seven on a fixed cadence in
the cross-team channel (PP10).
## 10. Non-goals
- Renaming or repointing main. Its contract is locked (P1).
- Eliminating release branches while ComfyUI pins exact frontend versions.
That coupling is a product decision owned elsewhere; this strategy minimizes
its cost rather than pretending it away.
- Standing up new long-lived environments. Existing surfaces (testcloud,
staging, per-PR previews) cover every tier the draft wanted.
- Changing ComfyUI core's own release process, cadence, or the soak policy it
requires; this document only defines what the frontend does on each axis.
- Prescribing the backend or cloud repo's deployment architecture (per-service
delivery work proceeds independently).
## 11. Requirements
**P0 (the strategy is not adopted without these):**
1. Freeze-as-code marker checked by version-bump and backport automation.
Acceptance: a bump dispatch against a frozen line fails with a clear error;
verified by test.
2. Prod pointer ref written only by the deploy pipeline after verified sync,
with a reconciling audit. Acceptance: mismatch alarms within one deploy
cycle.
3. Convergence invariant and release-content verification checks, with
heartbeat alerting. Acceptance: a deliberately mislabeled test PR is caught
before a train departs.
4. Drift SLO alerting at 28 days of line age or two minors of pin divergence.
Acceptance: alert fires in a rehearsal against a stale line.
5. Documented hotfix and rollback runbooks (6.9) with an hours-scale SLA.
Acceptance: one rehearsed hotfix per quarter meets the SLA.
6. Prerequisite tooling fixes: FE-1282, auto-merge setting, bot heartbeats.
Acceptance: a manually dispatched backport retry succeeds end to end; a
backport PR merges via repo auto-merge with the cron workaround retired;
killing a release bot raises its heartbeat alert.
**P1 (fast follows):**
7. Overnight release-gate suite stamping last-green tags; red verdict is an
incident with a named owner. Acceptance: a green run stamps the dated tag
within an hour; a red run opens an owned incident.
8. Automatic staging promotion on green; GitHub Environments protection rules
with deployment history as the prod gate. Acceptance: a green candidate
reaches staging with zero human actions, and a prod promotion without the
required reviewer is blocked in rehearsal.
9. Standing QA gate criteria with named owners replacing per-release plans.
Acceptance: the next release runs from the standing criteria with no
bespoke test plan authored.
**P2 (future, explicitly sequenced):**
10. Canary with metric-gated auto-ramp and auto-rollback. Acceptance: one
production auto-rollback triggered by canary metrics in a game day.
11. cloud/x.y retirement per the 6.10 checklist. Acceptance: the cutover
checklist fully executed, verified by a workflow inventory showing no
half-dead machinery.
12. Soak retirement gated on the custom-node harness guardrail. Acceptance:
the harness holds the escaped-regression guardrail flat for two full
cycles before any soak shortening.
## 12. Risk register
| ID | Risk | L | I | Level | Mitigation | Owner |
| --- | --- | --- | --- | --- | --- | --- |
| R1 | Custom-node harness stays unstaffed, so soak cannot shrink and core latency persists | High | High | Critical | Escalate staffing as the single gating dependency of the whole program; do not shorten the soak before the harness holds the guardrail | Eng leadership |
| R2 | ComfyUI pin adoption lags frontend trains, so drift SLO breaches are unresolvable frontend-side | High | High | Critical | Agree a pin-adoption SLA with core release stakeholders; SLO breaches tracing to an unmerged pin PR escalate to leadership instead of paging the rotation | FE eng manager with core stakeholders |
| R3 | Canary program slips; cloud branches linger in a half-retired state | Med | High | High | Retirement is the last step with explicit cutover criteria; interim state is exactly today's process | Release pipeline owner |
| R4 | Prod pointer or invariant checks silently break, recreating PP4 | Med | High | High | Heartbeat alerts plus reconciliation against the deploy system on every run; silence pages | DevOps |
| R5 | Drift SLO is ignored under deadline pressure | Med | Med | Medium | SLO breach pages the rotation and blocks the next minor cut until acknowledged | Rotation owner |
| R6 | Incremental rollout gives no visible "fixed" moment; cross-team pressure continues | Med | Med | Medium | Publish the section 9 metrics on a fixed cadence; make progress legible | FE eng manager |
| R7 | Flag debt accumulates as flags gate incomplete work on trunk | Med | Low | Low | Review-for-deletion date on every flag; monthly cleanup | FE leads |
Doing nothing carries its own critical risks (drift compounds, the next
missed backport ships another regression, rotation burnout continues); the
status quo is not the safe option.
## 13. Open questions
1. Cadence of the core train once the drift SLO lands: stay biweekly or move
to weekly? At current velocity the under-100 batch target in section 9 is
reachable only with weekly trains, so this question blocks that one
metric's horizon. (Rotation owner plus core release stakeholders; blocking
for the batch-size target, non-blocking for everything else.)
2. Should the prod pointer cover core GA and desktop as refs too
(`deployed/core-ga`), or is PyPI plus the pin authoritative enough?
(Frontend leads; non-blocking.)
3. Who owns the standing QA gate criteria documents long-term as QA staffing
changes? (QA plus FE eng manager; blocking for Phase 0 sign-off.)
4. Does the org-admin limitation on separate go-live approvers (surfaced
during FE-1176) need resolution before GitHub Environments become the prod
gate? (DevOps plus org admins; blocking for P1 item 8.)
## 14. References
Internal: `docs/release-process.md` (the operational runbook of record,
updated as phases land); the shipping-speed initiative and its release-gate
and canary design docs; the release rotation runbook; the
premature-version-bump post-mortem; FE-713, FE-1176, FE-1282, FE-602/BE-800;
the draft 4-tier proposal this document reviews.
External: see section 5 source list.

View File

@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[pnpm dev hangs]
B -->|Dev server stuck| C[nx serve hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
@@ -23,7 +23,7 @@ flowchart TD
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
@@ -41,11 +41,11 @@ flowchart TD
### Development Server Issues
#### Q: `pnpm dev` gets stuck and won't start
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
**Symptoms:**
- Command hangs during Vite startup
- Command hangs on "nx serve"
- Dev server doesn't respond
- Terminal appears frozen
@@ -65,7 +65,7 @@ flowchart TD
3. **Last resort - Full node_modules reset:**
```bash
pnpm clean:all && pnpm i
pnpm dlx rimraf node_modules && pnpm i
```
**Why this happens:**
@@ -73,7 +73,7 @@ flowchart TD
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- stale local build cache
- NX cache corruption
---

View File

@@ -3,11 +3,8 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
"lint": "eslint src --cache",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
@@ -36,5 +33,88 @@
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {
"tags": [
"scope:desktop",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite preview --config vite.config.mts"
}
},
"storybook": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook dev -p 6007"
}
},
"build-storybook": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook build -o dist/storybook"
},
"outputs": [
"{projectRoot}/dist/storybook"
]
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "eslint src --cache"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vue-tsc --noEmit -p tsconfig.json"
}
}
}
}
}

View File

@@ -1,12 +1,12 @@
<template>
<tr
class="border-y border-solid border-neutral-700"
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="w-16 text-center">
<td class="text-center w-16">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
@@ -14,7 +14,7 @@
{{ task.name }}
</p>
<Button
class="mx-2 inline-block"
class="inline-block mx-2"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
@@ -22,11 +22,11 @@
@click="toggle"
/>
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="px-4 text-right">
<td class="text-right px-4">
<Button
:icon="task.button?.icon"
:label="task.button?.text"

View File

@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -113,50 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Cloud nodes integration
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
### Production strictness
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
### Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
`apps/website/src/data/cloud-nodes.snapshot.json` via
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
`deploy-production` job hard-fails before `vercel build --prod` if the secret
is missing.
### Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs
to Comfy Cloud):
```bash
WEBSITE_CLOUD_API_KEY=\
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the `Release: Website` GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -190,4 +146,3 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot

View File

@@ -1,53 +1,23 @@
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import sitemap from '@astrojs/sitemap'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
const LOCALES = ['en', 'zh-CN'] as const
const DEFAULT_LOCALE = 'en'
const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
])
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')
return SITEMAP_EXCLUDED_PATHNAMES.has(pathname)
}
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
// Keep MDX punctuation verbatim; SmartyPants would turn the source's straight
// quotes into curly ones and drift from the rest of the site's copy.
markdown: { smartypants: false },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/',
'/zh-CN/terms-of-service': '/terms-of-service'
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [
vue(),
mdx(),
sitemap({
filter: (page) => !isExcludedFromSitemap(page)
})
],
integrations: [vue(), sitemap()],
vite: {
plugins: [tailwindcss()],
server: {
@@ -57,8 +27,8 @@ export default defineConfig({
}
},
i18n: {
locales: [...LOCALES],
defaultLocale: DEFAULT_LOCALE,
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false
}

View File

@@ -1,24 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

View File

@@ -1,140 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates/terms'
const SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
test.describe('Affiliate Terms — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('exposes one anchor per legal section in order', async ({ page }) => {
for (const id of SECTION_IDS) {
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
}
const orderedIds = await page.evaluate(
(ids) => {
const elements = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
return elements
.slice()
.sort((a, b) => {
const relation = a.compareDocumentPosition(b)
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
.map((el) => el.id)
},
[...SECTION_IDS]
)
expect(orderedIds).toEqual([...SECTION_IDS])
})
test('renders an effective date footer', async ({ page }) => {
await expect(page.getByText(/Effective Date:/)).toBeVisible()
})
test('skips internal-only sections (competitive analysis, open questions)', async ({
page
}) => {
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
await expect(
page.getByText(/Open questions for legal review/i)
).toHaveCount(0)
})
})
test.describe('Affiliate Terms — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('clicking a desktop TOC link scrolls to the matching section', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await expect(desktopToc).toBeVisible()
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
await link.click()
const target = page.locator('[id="5-prohibited-activities"]')
await expect(target).toBeInViewport()
})
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
await expect
.poll(() => page.evaluate(() => window.location.hash))
.toBe('#7-termination')
})
})
test.describe('Affiliate Terms — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows a collapsed accordion TOC by default', async ({ page }) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await expect(accordion).toBeVisible()
await expect(accordion).not.toHaveAttribute('open', '')
})
test('expanding the accordion reveals every section link', async ({
page
}) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await accordion.locator('summary').click()
await expect(accordion).toHaveAttribute('open', '')
for (const id of SECTION_IDS) {
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
}
})
test('headings remain readable at narrow viewports without horizontal overflow', async ({
page
}) => {
const heading = page.getByRole('heading', { name: '1. Program Overview' })
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'heading box').not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
})
})

View File

@@ -1,152 +0,0 @@
import { expect } from '@playwright/test'
import { affiliateFaqs } from '../src/data/affiliateFaq'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates'
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
const TERMS_PATH = '/affiliates/terms'
const FAQ_COUNT = affiliateFaqs.length
const FIRST_FAQ = affiliateFaqs[0]
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
test.describe('Affiliates landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('renders the closing CTA heading and apply button', async ({ page }) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const ctaHeading = ctaSection.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await expect(applyButton).toBeVisible()
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
await expect(applyButton).toHaveAttribute('target', '_blank')
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
})
test('CTA section links to the affiliate terms page in the same tab', async ({
page
}) => {
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
await termsLink.scrollIntoViewIfNeeded()
await expect(termsLink).toBeVisible()
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
await expect(termsLink).not.toHaveAttribute('target', '_blank')
})
})
test.describe('Affiliates landing — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('emits FAQPage structured data with one entry per FAQ', async ({
page
}) => {
const faqJsonLd = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]'
)
)
const match = scripts.find((s) =>
(s.textContent ?? '').includes('FAQPage')
)
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({
page,
context
}) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await applyButton.scrollIntoViewIfNeeded()
const popupPromise = context.waitForEvent('page')
await applyButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const popupUrl = popup.url()
expect(
popupUrl.includes('forms.gle/RS8L2ttcuGap4Q1v6') ||
popupUrl.includes('docs.google.com/forms')
).toBe(true)
await popup.close()
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: FIRST_FAQ.question.en
})
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
})
})
test.describe('Affiliates landing — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero heading at narrow viewports', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
})
test('closing CTA stays within the viewport width', async ({ page }) => {
const ctaHeading = page.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const box = await ctaHeading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
})
})

View File

@@ -1,51 +0,0 @@
import { expect } from '@playwright/test'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
import { test } from './fixtures/blockExternalMedia'
test.describe('Brand portal @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/brand')
})
test('renders each brand guideline section', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'One mark, many dimensions.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Every color earns its place.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Precise, never cute.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Trademark guidelines.' })
).toBeVisible()
})
test('shows all four marks', async ({ page }) => {
const logos = page.locator('#logos')
for (const name of [
'Core Logo',
'Logomark',
'Icon',
'Amplified Logomark'
]) {
await expect(logos.getByText(name, { exact: true })).toBeVisible()
}
})
test('the hero ctas open the gated guidelines and the logo bundle', async ({
page
}) => {
await expect(
page.getByRole('link', { name: 'View brand guidelines' })
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
await expect(
page.getByRole('link', { name: 'Download logos' })
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
})
})

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers - Comfy')
await expect(page).toHaveTitle('Careers Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -23,56 +23,32 @@ test.describe('Careers page @smoke', () => {
expect(await roles.count()).toBeGreaterThan(0)
})
test('clicking a department button scrolls to and activates that section', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
})
})
test.describe('Careers page role links', () => {
test('each role links to the Ashby job description page, not the application form', async ({
page
}) => {
await page.goto('/careers')
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
expect(href).not.toMatch(/\/application\/?$/)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -1,169 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud nodes page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/supported-nodes')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud - supported by default'
)
})
test('renders at least one pack card', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
await expect(cards.first()).toBeVisible()
expect(await cards.count()).toBeGreaterThan(0)
})
test('search input filters cards down', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
const filteredCards = page.getByTestId('cloud-node-pack-card')
await expect(filteredCards.first()).toBeVisible()
const filteredCount = await filteredCards.count()
expect(filteredCount).toBeGreaterThan(0)
expect(filteredCount).toBeLessThanOrEqual(initialCount)
})
test('renders banner image or fallback element', async ({ page }) => {
const banners = page.getByTestId('cloud-node-pack-banner')
await expect(banners.first()).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
test('direct pack detail route renders node entries', async ({ page }) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-detail-node').first()
).toBeVisible()
})
test('search with no matches shows empty state', async ({ page }) => {
await page
.getByTestId('cloud-nodes-search')
.fill('zzzz-this-pack-does-not-exist')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
await expect(page.getByText('No matching packs')).toBeVisible()
})
test('clearing search restores the full list', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
await expect(cards.first()).toBeVisible()
await page.getByTestId('cloud-nodes-search').fill('')
await expect(cards).toHaveCount(initialCount)
})
test('search matches against node display names, not just pack names', async ({
page
}) => {
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
await expect(
page.getByTestId('cloud-node-pack-card-link').first()
).toContainText('Impact Pack')
})
test('switching sort to A → Z reorders cards alphabetically', async ({
page
}) => {
await page.locator('#cloud-nodes-sort').selectOption('az')
const firstName = await page
.getByTestId('cloud-node-pack-card-link')
.first()
.textContent()
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
})
test('list grid carries a localized aria-label', async ({ page }) => {
await expect(
page.getByRole('list', {
name: 'Custom-node packs supported on Comfy Cloud'
})
).toBeVisible()
})
test('clicking the back link returns to the index from a detail page', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await page.getByRole('link', { name: 'Back to all packs' }).click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
})
test('detail page renders publisher and external repo link', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
const repoLink = page.getByRole('link', {
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
})
await expect(repoLink).toBeVisible()
await expect(repoLink).toHaveAttribute('rel', /noopener/)
})
test('unknown pack slug 404s', async ({ page }) => {
const response = await page.goto(
'/cloud/supported-nodes/this-pack-does-not-exist'
)
expect(response?.status()).toBe(404)
})
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
const jsonLd = page.locator('script[type="application/ld+json"]')
const ldBlocks = await jsonLd.allTextContents()
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
})
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
const ldBlocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
for (const block of ldBlocks) {
expect(block).not.toContain('</script')
}
})
})
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
test('renders localized title and packs', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-banner').first()
).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 6 model cards are visible', async ({
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(6)
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

View File

@@ -1,61 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

View File

@@ -1,73 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customer story detail @smoke', () => {
test('renders the migrated article: hero, section nav, and body', async ({
page
}) => {
await page.goto('/customers/series-entertainment')
await expect(
page.getByRole('heading', {
level: 1,
name: /Series Entertainment Rebuilt Game and Video Production/i
})
).toBeVisible()
const nav = page.getByRole('navigation', { name: 'Category filter' })
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
await expect(nav.getByRole('button', { name: 'CONCLUSION' })).toBeVisible()
// Section title rendered from the MDX <Section title> wrapper.
await expect(
page.getByRole('heading', {
name: 'The Output Series Achieved Using ComfyUI'
})
).toBeVisible()
})
test('section nav highlights the section the reader selects', async ({
page
}) => {
await page.goto('/customers/series-entertainment')
const nav = page.getByRole('navigation', { name: 'Category filter' })
const intro = nav.getByRole('button', { name: 'INTRO' })
const problem = nav.getByRole('button', { name: 'THE PROBLEM' })
await expect(intro).toHaveAttribute('aria-pressed', 'true')
await problem.click()
await expect(problem).toHaveAttribute('aria-pressed', 'true')
})
test('shows the read-more link only when an external source exists', async ({
page
}) => {
await page.goto('/customers/open-story-movement')
await expect(
page.getByRole('link', { name: /read more on this topic/i })
).toBeVisible()
// series-entertainment only redirected back to itself, so the link is gone.
await page.goto('/customers/series-entertainment')
await expect(
page.getByRole('link', { name: /read more on this topic/i })
).toHaveCount(0)
})
test('links to the next story in the what-is-next section', async ({
page
}) => {
await page.goto('/customers/series-entertainment')
const nextLink = page.getByRole('link', { name: /view article/i })
await expect(nextLink).toBeVisible()
// Links to another customer story, without coupling the test to the
// specific slug or sort order.
await expect(nextLink).toHaveAttribute('href', /^\/customers\/[a-z0-9-]+$/)
await expect(nextLink).not.toHaveAttribute(
'href',
'/customers/series-entertainment'
)
})
})

View File

@@ -1,33 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -1,79 +0,0 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
await expect(page.getByText('Coming Soon')).toBeVisible()
})
test('non-existent demo returns 404', async ({ page }) => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
})

View File

@@ -4,10 +4,6 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -15,9 +11,7 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop - Run AI on Your Hardware'
)
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
@@ -44,80 +38,20 @@ test.describe('Download page @smoke', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI#installing'
'https://github.com/Comfy-Org/ComfyUI'
)
await context.close()
})
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
browser
}) => {
const context = await browser.newContext({ userAgent: LINUX_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const windowsBtn = hero.locator(
'a[href="https://comfy.org/download/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
const macBtn = hero.locator(
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
)
await expect(macBtn).toBeVisible()
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toHaveCount(2)
await context.close()
})
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
const context = await browser.newContext({ userAgent: IPHONE_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toBeHidden()
await expect(
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
).toBeVisible()
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
@@ -242,7 +176,7 @@ test.describe('Download page mobile @mobile', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {
@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
const downloadLink = section.getByRole('link', { name: 'Download Local' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')

View File

@@ -1,231 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { drops } from '../src/data/drops'
import type { Locale } from '../src/i18n/translations'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH_EN = '/launches'
const PATH_ZH = '/zh-CN/launches'
const CLOUD_URL = 'https://cloud.comfy.org'
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
[PATH_EN, 'en'],
[PATH_ZH, 'zh-CN']
]
function heroSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', locale)
})
})
}
function ctaSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', locale)
})
})
}
function dropsSection(page: Page, locale: Locale) {
return page.locator('section').filter({
has: page.getByRole('heading', {
level: 2,
name: t('launches.section.title', locale)
})
})
}
test.describe('Launches landing — desktop @smoke', () => {
test('renders the configured title at /launches', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page).toHaveTitle(t('launches.page.title', 'en'))
})
test('renders the localized title at /zh-CN/launches', async ({ page }) => {
await page.goto(PATH_ZH)
await expect(page).toHaveTitle(t('launches.page.title', 'zh-CN'))
})
test('is indexable at both locales', async ({ page }) => {
await page.goto(PATH_EN)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
await page.goto(PATH_ZH)
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('hero h1 renders the localized title in both locales', async ({
page
}) => {
await page.goto(PATH_EN)
await expect(
page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', 'en')
})
).toBeVisible()
await page.goto(PATH_ZH)
await expect(
page.getByRole('heading', {
level: 1,
name: t('launches.hero.title', 'zh-CN')
})
).toBeVisible()
})
test('hero primary CTA links to /download per locale', async ({ page }) => {
for (const [path, locale, expectedHref] of [
[PATH_EN, 'en', '/download'],
[PATH_ZH, 'zh-CN', '/zh-CN/download']
] as const) {
await page.goto(path)
const primary = heroSection(page, locale).getByRole('link', {
name: t('launches.hero.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', expectedHref)
}
})
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const secondary = heroSection(page, locale).getByRole('link', {
name: t('launches.hero.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('closing CTA shows heading and both action buttons in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = ctaSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', locale)
})
).toBeVisible()
const primary = section.getByRole('link', {
name: t('launches.cta.primary', locale)
})
await expect(primary).toBeVisible()
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
await expect(primary).toHaveAttribute('target', '_blank')
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
const secondary = section.getByRole('link', {
name: t('launches.cta.secondary', locale)
})
await expect(secondary).toBeVisible()
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
await expect(secondary).toHaveAttribute('target', '_blank')
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
}
})
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
page
}) => {
for (const [path, locale] of LOCALES) {
await page.goto(path)
const section = dropsSection(page, locale)
await expect(
section.getByRole('heading', {
level: 2,
name: t('launches.section.title', locale)
})
).toBeVisible()
const cards = section.locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
for (const [i, drop] of drops.entries()) {
const card = cards.nth(i)
await expect(card).toContainText(drop.title[locale])
const explore = card.getByRole('link', {
name: drop.cta.label[locale]
})
await expect(explore).toBeVisible()
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
}
}
})
test('desktop: first 4 drop cards are wider than cards 5+', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
await expect
.poll(async () => {
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
return firstWidth - fifthWidth
})
.toBeGreaterThan(0)
})
})
test.describe('Launches landing — mobile @mobile', () => {
test('drops grid stacks in a single column at mobile width', async ({
page
}) => {
await page.goto(PATH_EN)
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
await expect(cards).toHaveCount(drops.length)
const viewport = page.viewportSize()
expect(viewport, 'viewport size').not.toBeNull()
await expect
.poll(async () => (await cards.nth(0).boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(viewport!.width * 0.7)
await expect
.poll(async () => {
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
if (!firstBox || !secondBox) return false
return secondBox.y >= firstBox.y + firstBox.height
})
.toBe(true)
})
test('closing CTA heading stays within viewport width', async ({ page }) => {
await page.goto(PATH_EN)
const heading = page.getByRole('heading', {
level: 2,
name: t('launches.cta.heading', 'en')
})
await heading.scrollIntoViewIfNeeded()
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
const viewport = page.viewportSize()
expect(viewport, 'viewport size').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
})
})

View File

@@ -1,158 +0,0 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning - Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -1,112 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
})
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
page
}) => {
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
await expect(installLinks).toHaveCount(2)
for (const link of await installLinks.all()) {
await expect(link).toHaveAttribute('href', '#setup')
}
})
test('setup section shows both install options', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
})
test('capabilities section shows all six tool cards', async ({ page }) => {
for (const title of [
'Generate anything',
'Search the ecosystem',
'Run real workflows',
'Direct any model',
'Generate in batches',
'Ship it as an app'
]) {
await expect(
page.getByRole('heading', { name: title, exact: true })
).toBeVisible()
}
})
test('FAQ lists nine questions and autolinks the server URL', async ({
page
}) => {
const triggers = page.locator('[id^="faq-trigger-"]')
await triggers.first().scrollIntoViewIfNeeded()
await expect(triggers).toHaveCount(9)
await page.getByRole('button', { name: "What's the server URL?" }).click()
await expect(
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
).toHaveAttribute('href', MCP_ENDPOINT)
})
})
test.describe('MCP page zh-CN @smoke', () => {
test('setup section renders localized options', async ({ page }) => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

View File

@@ -2,13 +2,6 @@ import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const TOP_LEVEL_LABELS = [
'Products',
'Pricing',
'Community',
'Company'
] as const
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
@@ -24,38 +17,22 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of TOP_LEVEL_LABELS) {
await expect(
desktopLinks.getByText(label, { exact: true }).first()
).toBeVisible()
for (const label of [
'PRODUCTS',
'PRICING',
'COMMUNITY',
'RESOURCES',
'COMPANY'
]) {
await expect(desktopLinks.getByText(label).first()).toBeVisible()
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
@@ -72,13 +49,13 @@ test.describe('Desktop dropdown @interaction', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: 'Products'
name: /PRODUCTS/i
})
await productsButton.hover()
const dropdown = nav.getByTestId('nav-dropdown')
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Desktop',
'Comfy Local',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
@@ -90,22 +67,21 @@ test.describe('Desktop dropdown @interaction', () => {
test('moving mouse away closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
const viewport = page.viewportSize()
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
await page.locator('main').hover()
await expect(comfyLocal).toBeHidden()
})
test('Escape key closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
@@ -129,48 +105,37 @@ test.describe('Mobile menu @mobile', () => {
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
const menu = page.locator('#site-mobile-menu')
await expect(menu).toBeVisible()
for (const label of ['Products', 'Pricing', 'Community']) {
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
await expect(menu.getByText(label).first()).toBeVisible()
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
await menu.getByRole('button', { name: 'Products' }).click()
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
})
test('CTA buttons visible in mobile menu', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})
})

View File

@@ -1,115 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { test } from './fixtures/blockExternalMedia'
const CLOUD_URL = externalLinks.cloud
const PLATFORM_USAGE_URL = externalLinks.platformUsage
const SUPPORT_URL = externalLinks.support
const DOCS_SUBSCRIPTION_URL = externalLinks.docsSubscription
async function expectNoIndex(page: Page) {
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex, nofollow'
)
}
test.describe('Payment success page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/payment/success')
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expectNoIndex(page)
})
test('shows success heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Payment successful/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/Thanks for your purchase/i)).toBeVisible()
})
test('primary CTA links to Comfy Cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /CONTINUE TO COMFY CLOUD/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', CLOUD_URL)
})
test('secondary CTA links to platform usage & payments page', async ({
page
}) => {
const cta = page.getByRole('link', { name: /VIEW USAGE & PAYMENTS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', PLATFORM_USAGE_URL)
})
})
test.describe('Payment failed page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/payment/failed')
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expectNoIndex(page)
})
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Unable to complete payment/i,
level: 1
})
).toBeVisible()
await expect(page.getByText(/payment didn't go through/i)).toBeVisible()
})
test('primary CTA links to support help center', async ({ page }) => {
const cta = page.getByRole('link', { name: /CONTACT SUPPORT/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', SUPPORT_URL)
})
test('secondary CTA links to subscription docs', async ({ page }) => {
const cta = page.getByRole('link', { name: /READ SUBSCRIPTION DOCS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
})
})
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
).toBeVisible()
await expect(
page.getByRole('link', { name: '前往 COMFY CLOUD' })
).toHaveAttribute('href', CLOUD_URL)
await expect(
page.getByRole('link', { name: '查看用量与支付' })
).toHaveAttribute('href', PLATFORM_USAGE_URL)
})
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',
SUPPORT_URL
)
await expect(
page.getByRole('link', { name: '查看订阅文档' })
).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
})
})

View File

@@ -1,56 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Pricing page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/pricing')
})
test('shows the three paid tiers and Enterprise', async ({ page }) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
await expect(
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
).toBeVisible()
}
await expect(
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
).toBeVisible()
})
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
page
}) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
await expect(
pricingGrid.locator('span', { hasText: /^FREE$/ })
).toHaveCount(0)
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
0
)
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
})
})
test.describe('Cloud pricing teaser @smoke', () => {
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
page
}) => {
await page.goto('/cloud')
await expect(
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
).toHaveCount(0)
})
})

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {
@@ -126,7 +126,6 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/enterprise',
'/cloud/pricing',
'/contact',
'/download',

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: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 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: 45 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 57 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: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -15,42 +15,113 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@lucide/vue": "catalog:",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
"class-variance-authority": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@astrojs/mdx": "catalog:",
"@astrojs/vue": "catalog:",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vue-component-type-helpers": "catalog:"
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}
}

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 100 }
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {

View File

@@ -1,3 +0,0 @@
<svg width="147" height="159" viewBox="0 0 147 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.437 118.915C116.712 117.983 116.863 117 116.863 115.967C116.863 110.25 112.252 105.615 106.564 105.615H60.4108C57.9301 105.64 55.9006 103.625 55.9006 101.131C55.9006 100.678 55.9759 100.25 56.0761 99.8468L68.504 56.3212C69.0302 54.4069 70.7841 52.9963 72.8387 52.9963L119.168 52.946C128.94 52.946 137.182 46.3214 139.664 37.2788L146.63 13.0223C146.854 12.1658 146.98 11.2338 146.98 10.3019C146.98 4.60938 142.395 0 136.733 0H80.6814C70.9594 0 62.7409 6.57416 60.2104 15.5159L55.4998 32.0647C54.9485 33.9539 53.2197 35.3392 51.1651 35.3392H37.7098C28.0631 35.3392 19.9198 41.7875 17.3139 50.6287L0.375936 110.098C0.125241 110.98 0 111.937 0 112.894C0 118.612 4.61042 123.247 10.2981 123.247H23.5278C26.0085 123.247 28.038 125.262 28.038 127.781C28.038 128.209 27.988 128.637 27.8627 129.04L23.1771 145.438C22.9515 146.32 22.8012 147.226 22.8012 148.158C22.8012 153.851 27.3866 158.461 33.0492 158.461L89.1253 158.409C98.8722 158.409 107.091 151.81 109.596 142.819L116.412 118.94L116.437 118.915Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -1,4 +0,0 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="142" height="142" rx="33" fill="#211927"/>
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,4 +0,0 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="142" height="142" rx="33" fill="#F2FF59"/>
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#211927"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="148" height="159" viewBox="0 0 148 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.653 119.135C116.929 118.202 117.08 117.217 117.08 116.183C117.08 110.454 112.461 105.811 106.762 105.811H60.523C58.0377 105.836 56.0044 103.817 56.0044 101.319C56.0044 100.865 56.0798 100.436 56.1802 100.032L68.6312 56.4258C69.1584 54.508 70.9155 53.0947 72.9739 53.0947L119.389 53.0443C129.179 53.0443 137.437 46.4074 139.924 37.348L146.903 13.0464C147.127 12.1884 147.253 11.2547 147.253 10.321C147.253 4.61794 142.659 0 136.987 0H80.8312C71.0912 0 62.8574 6.58636 60.3222 15.5448L55.6028 32.1242C55.0505 34.017 53.3185 35.4049 51.2601 35.4049H37.7798C28.1152 35.4049 19.9568 41.8651 17.346 50.7227L0.376634 110.303C0.125474 111.186 0 112.145 0 113.104C0 118.832 4.61899 123.476 10.3173 123.476H23.5715C26.0568 123.476 28.0901 125.495 28.0901 128.018C28.0901 128.447 28.0399 128.876 27.9144 129.28L23.2202 145.708C22.9941 146.591 22.8435 147.5 22.8435 148.433C22.8435 154.137 27.4374 158.755 33.1106 158.755L89.2908 158.704C99.0558 158.704 107.29 152.092 109.8 143.084L116.628 119.16L116.653 119.135Z" fill="#211927"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

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