diff --git a/.claude/skills/add-model-page/SKILL.md b/.claude/skills/add-model-page/SKILL.md new file mode 100644 index 0000000000..0b7fe4b81e --- /dev/null +++ b/.claude/skills/add-model-page/SKILL.md @@ -0,0 +1,173 @@ +--- +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 ` +- `Update the model page for ` +- `Remove 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 | diff --git a/.claude/skills/backport-management/SKILL.md b/.claude/skills/backport-management/SKILL.md index 138eb98a84..de77272667 100644 --- a/.claude/skills/backport-management/SKILL.md +++ b/.claude/skills/backport-management/SKILL.md @@ -9,13 +9,18 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br ## Quick Start -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`) +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`) ## System Context @@ -107,6 +112,35 @@ 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 `. 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.** @@ -147,6 +181,26 @@ 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: diff --git a/.claude/skills/backport-management/reference/analysis.md b/.claude/skills/backport-management/reference/analysis.md index dd6a41b394..705448b51f 100644 --- a/.claude/skills/backport-management/reference/analysis.md +++ b/.claude/skills/backport-management/reference/analysis.md @@ -39,6 +39,89 @@ 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. diff --git a/.claude/skills/backport-management/reference/discovery.md b/.claude/skills/backport-management/reference/discovery.md index ac826ce764..ab28e0815f 100644 --- a/.claude/skills/backport-management/reference/discovery.md +++ b/.claude/skills/backport-management/reference/discovery.md @@ -1,5 +1,11 @@ # 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): @@ -36,7 +42,43 @@ 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 | Category | Flagged by Bot? | Decision | +| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision | diff --git a/.claude/skills/backport-management/reference/execution.md b/.claude/skills/backport-management/reference/execution.md index 2bc6f9f78a..0195091e71 100644 --- a/.claude/skills/backport-management/reference/execution.md +++ b/.claude/skills/backport-management/reference/execution.md @@ -6,6 +6,43 @@ 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 @@ -88,6 +125,39 @@ 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 -- run` + # with no arg matchers 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 -- run "${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)" \ @@ -243,6 +313,9 @@ 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 && 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 @@ -268,3 +341,40 @@ 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 -- run ` ✅ (N/N passing) +- `pnpm exec eslint ` ✅ (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?" diff --git a/.claude/skills/writing-playwright-tests/SKILL.md b/.claude/skills/writing-playwright-tests/SKILL.md index 84a39210a0..efc20dff85 100644 --- a/.claude/skills/writing-playwright-tests/SKILL.md +++ b/.claude/skills/writing-playwright-tests/SKILL.md @@ -114,7 +114,7 @@ await expect(async () => { ## CI Debugging 1. Download artifacts from failed CI run -2. Extract and view trace: `npx playwright show-trace trace.zip` +2. Extract and view trace: `pnpm dlx 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` diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 20af82bec6..35c5d31183 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -19,15 +19,26 @@ reviews: - 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`), 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. + 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. - 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 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. - 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: | diff --git a/.github/workflows/ci-perf-report.yaml b/.github/workflows/ci-perf-report.yaml index bd6c286f37..94dad8b916 100644 --- a/.github/workflows/ci-perf-report.yaml +++ b/.github/workflows/ci-perf-report.yaml @@ -54,10 +54,14 @@ 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 - run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3 + 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 - name: Upload perf metrics if: always() diff --git a/.github/workflows/ci-tests-e2e-coverage.yaml b/.github/workflows/ci-tests-e2e-coverage.yaml index 2944bf0c82..bf05c37a4b 100644 --- a/.github/workflows/ci-tests-e2e-coverage.yaml +++ b/.github/workflows/ci-tests-e2e-coverage.yaml @@ -20,6 +20,8 @@ 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 @@ -37,31 +39,33 @@ 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) @@ -82,7 +86,7 @@ jobs: done - name: Upload merged coverage data - if: always() + if: steps.coverage-shards.outputs.has-coverage == 'true' uses: actions/upload-artifact@v6 with: name: e2e-coverage @@ -91,7 +95,7 @@ jobs: if-no-files-found: warn - name: Upload E2E coverage to Codecov - if: always() + if: steps.coverage-shards.outputs.has-coverage == 'true' uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: files: coverage/playwright/coverage.lcov @@ -100,6 +104,7 @@ 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." @@ -114,6 +119,7 @@ jobs: --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 @@ -122,7 +128,9 @@ jobs: deploy: needs: merge - if: github.event.workflow_run.head_branch == 'main' + if: > + github.event.workflow_run.head_branch == 'main' && + needs.merge.outputs.has-coverage == 'true' runs-on: ubuntu-latest permissions: pages: write diff --git a/.github/workflows/ci-vercel-website-preview.yaml b/.github/workflows/ci-vercel-website-preview.yaml index 3588cfc2bf..99e2016b47 100644 --- a/.github/workflows/ci-vercel-website-preview.yaml +++ b/.github/workflows/ci-vercel-website-preview.yaml @@ -4,6 +4,9 @@ name: 'CI: Vercel Website Preview' on: pull_request: types: [opened, synchronize, reopened] + branches-ignore: + - 'core/**' + - 'cloud/**' paths: - 'apps/website/**' - 'packages/design-system/**' diff --git a/.github/workflows/model-page-discovery.yaml b/.github/workflows/model-page-discovery.yaml new file mode 100644 index 0000000000..457c7546aa --- /dev/null +++ b/.github/workflows/model-page-discovery.yaml @@ -0,0 +1,123 @@ +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." diff --git a/.oxlintrc.json b/.oxlintrc.json index 19700f80c2..66142ab5ca 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,6 +85,15 @@ "typescript/no-unused-vars": "off", "unicorn/no-empty-file": "off", "vitest/require-mock-type-parameters": "off", + "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", diff --git a/apps/desktop-ui/src/i18n.ts b/apps/desktop-ui/src/i18n.ts index 52db23e091..fa3367286d 100644 --- a/apps/desktop-ui/src/i18n.ts +++ b/apps/desktop-ui/src/i18n.ts @@ -9,6 +9,7 @@ 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< @@ -167,7 +168,7 @@ const messages: Record = { export const i18n = createI18n({ // Must set `false`, as Vue I18n Legacy API is for Vue 2 legacy: false, - locale: navigator.language.split('-')[0] || 'en', + locale: getDefaultLocale(), fallbackLocale: 'en', messages, // Ignore warnings for locale options as each option is in its own language. diff --git a/apps/website/README.md b/apps/website/README.md index 047940ed02..94468614c4 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -113,6 +113,12 @@ 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. + ## HubSpot contact form The contact page uses HubSpot's hosted form embed for the interest form: @@ -146,3 +152,4 @@ 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 diff --git a/apps/website/astro.config.ts b/apps/website/astro.config.ts index e57aef5cd6..6c542b3a6b 100644 --- a/apps/website/astro.config.ts +++ b/apps/website/astro.config.ts @@ -3,6 +3,23 @@ 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}`) + ) +) + +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', @@ -17,7 +34,12 @@ export default defineConfig({ assets: '_website' }, devToolbar: { enabled: !process.env.NO_TOOLBAR }, - integrations: [vue(), sitemap()], + integrations: [ + vue(), + sitemap({ + filter: (page) => !isExcludedFromSitemap(page) + }) + ], vite: { plugins: [tailwindcss()], server: { @@ -27,8 +49,8 @@ export default defineConfig({ } }, i18n: { - locales: ['en', 'zh-CN'], - defaultLocale: 'en', + locales: [...LOCALES], + defaultLocale: DEFAULT_LOCALE, routing: { prefixDefaultLocale: false } diff --git a/apps/website/e2e/careers.spec.ts b/apps/website/e2e/careers.spec.ts index 6f97c99f86..22263c1bfc 100644 --- a/apps/website/e2e/careers.spec.ts +++ b/apps/website/e2e/careers.spec.ts @@ -23,26 +23,50 @@ test.describe('Careers page @smoke', () => { expect(await roles.count()).toBeGreaterThan(0) }) - test('each role links to jobs.ashbyhq.com', async ({ page }) => { + 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') 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', () => { diff --git a/apps/website/e2e/cloud-nodes.spec.ts b/apps/website/e2e/cloud-nodes.spec.ts new file mode 100644 index 0000000000..9f99bf453b --- /dev/null +++ b/apps/website/e2e/cloud-nodes.spec.ts @@ -0,0 +1,169 @@ +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(' { + 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() + }) +}) diff --git a/apps/website/e2e/content-section.spec.ts b/apps/website/e2e/content-section.spec.ts new file mode 100644 index 0000000000..6d1a6d9265 --- /dev/null +++ b/apps/website/e2e/content-section.spec.ts @@ -0,0 +1,61 @@ +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') + }) + } +) diff --git a/apps/website/e2e/customers.spec.ts b/apps/website/e2e/customers.spec.ts new file mode 100644 index 0000000000..5d073473da --- /dev/null +++ b/apps/website/e2e/customers.spec.ts @@ -0,0 +1,33 @@ +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 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( + 'img[alt="Comfy 3D logo"]' + ) + if (!img) return null + img.removeAttribute('src') + return img.getBoundingClientRect().height + }) + + expect(heightWhileUnloaded).not.toBeNull() + expect(heightWhileUnloaded!).toBeGreaterThan(100) + }) +}) diff --git a/apps/website/e2e/demos.spec.ts b/apps/website/e2e/demos.spec.ts new file mode 100644 index 0000000000..bbf333e3e6 --- /dev/null +++ b/apps/website/e2e/demos.spec.ts @@ -0,0 +1,79 @@ +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) + }) +}) diff --git a/apps/website/e2e/download.spec.ts b/apps/website/e2e/download.spec.ts index 1afa483d1f..1e837ac950 100644 --- a/apps/website/e2e/download.spec.ts +++ b/apps/website/e2e/download.spec.ts @@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => { await expect(githubBtn).toBeVisible() await expect(githubBtn).toHaveAttribute( 'href', - 'https://github.com/Comfy-Org/ComfyUI' + 'https://github.com/Comfy-Org/ComfyUI#installing' ) await context.close() diff --git a/apps/website/e2e/payment.spec.ts b/apps/website/e2e/payment.spec.ts new file mode 100644 index 0000000000..2a2e8f535c --- /dev/null +++ b/apps/website/e2e/payment.spec.ts @@ -0,0 +1,115 @@ +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) + }) +}) diff --git a/apps/website/e2e/pricing.spec.ts b/apps/website/e2e/pricing.spec.ts new file mode 100644 index 0000000000..05520f0e5b --- /dev/null +++ b/apps/website/e2e/pricing.spec.ts @@ -0,0 +1,56 @@ +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) + }) +}) diff --git a/apps/website/e2e/responsive.spec.ts b/apps/website/e2e/responsive.spec.ts index c77e8e4ec7..6488170457 100644 --- a/apps/website/e2e/responsive.spec.ts +++ b/apps/website/e2e/responsive.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test' import { expect } from '@playwright/test' import { test } from './fixtures/blockExternalMedia' @@ -47,4 +48,105 @@ 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 { + await page.locator(selector).first().waitFor() + return page.evaluate((sel) => { + const tracks = Array.from( + document.querySelectorAll(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) +} diff --git a/apps/website/e2e/visual-responsive.spec.ts b/apps/website/e2e/visual-responsive.spec.ts index 1954901d85..060e75cfd8 100644 --- a/apps/website/e2e/visual-responsive.spec.ts +++ b/apps/website/e2e/visual-responsive.spec.ts @@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) { } async function navigateAndSettle(page: Page, url: string) { - await page.goto(url) - await page.waitForLoadState('networkidle') + await page.goto(url, { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('load') } test.describe('Home', { tag: '@visual' }, () => { @@ -126,6 +126,7 @@ test.describe('Overflow guards', { tag: '@visual' }, () => { const pages = [ '/', '/cloud', + '/cloud/enterprise', '/cloud/pricing', '/contact', '/download', diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png index bda1554132..bdfa934d5f 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png index 9f1ffb3b50..33bbea1737 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png index 636ff5954f..5ce1c80434 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png index 68e1cf8bcd..695a4684aa 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png index 1a0b83ef45..780cc7b555 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png index 35f66b2859..0290e4e062 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png index 20d8be7838..13f924097d 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png index 718fb32ef7..70b26c7ca3 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png index 4017ab1aba..d5129a7fb1 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png index fff724b671..d6f83d0fa2 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png differ diff --git a/apps/website/package.json b/apps/website/package.json index 5a4aa57c3b..8a34eca2cc 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -15,11 +15,15 @@ "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" + "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" }, "dependencies": { "@astrojs/sitemap": "catalog:", "@comfyorg/design-system": "workspace:*", + "@comfyorg/object-info-parser": "workspace:*", + "@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*", "@vercel/analytics": "catalog:", "@vueuse/core": "catalog:", @@ -27,6 +31,7 @@ "gsap": "catalog:", "lenis": "catalog:", "posthog-js": "catalog:", + "three": "catalog:", "vue": "catalog:", "zod": "catalog:" }, diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index af51992ee8..b6100286bc 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ ? [['html'], ['json', { outputFile: 'results.json' }]] : 'html', expect: { - toHaveScreenshot: { maxDiffPixels: 50 } + toHaveScreenshot: { maxDiffPixels: 100 } }, ...maybeLocalOptions, webServer: { diff --git a/apps/website/public/assets/images/fallback-gradient-avatar.svg b/apps/website/public/assets/images/fallback-gradient-avatar.svg new file mode 100644 index 0000000000..90b860f619 --- /dev/null +++ b/apps/website/public/assets/images/fallback-gradient-avatar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/demos/community-workflows-og.png b/apps/website/public/images/demos/community-workflows-og.png new file mode 100644 index 0000000000..7e46105c88 Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-og.png differ diff --git a/apps/website/public/images/demos/community-workflows-thumb.webp b/apps/website/public/images/demos/community-workflows-thumb.webp new file mode 100644 index 0000000000..0e2427c14c Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-thumb.webp differ diff --git a/apps/website/public/images/demos/image-to-video-og.png b/apps/website/public/images/demos/image-to-video-og.png new file mode 100644 index 0000000000..144ca94c41 Binary files /dev/null and b/apps/website/public/images/demos/image-to-video-og.png differ diff --git a/apps/website/public/images/demos/image-to-video-thumb.webp b/apps/website/public/images/demos/image-to-video-thumb.webp new file mode 100644 index 0000000000..24119e7b64 Binary files /dev/null and b/apps/website/public/images/demos/image-to-video-thumb.webp differ diff --git a/apps/website/public/images/demos/workflow-templates-og.png b/apps/website/public/images/demos/workflow-templates-og.png new file mode 100644 index 0000000000..f13a49c448 Binary files /dev/null and b/apps/website/public/images/demos/workflow-templates-og.png differ diff --git a/apps/website/public/images/demos/workflow-templates-thumb.webp b/apps/website/public/images/demos/workflow-templates-thumb.webp new file mode 100644 index 0000000000..7c2e78ab4a Binary files /dev/null and b/apps/website/public/images/demos/workflow-templates-thumb.webp differ diff --git a/apps/website/public/robots.txt b/apps/website/public/robots.txt index 5e6114b55e..da06a725ac 100644 --- a/apps/website/public/robots.txt +++ b/apps/website/public/robots.txt @@ -29,5 +29,30 @@ Allow: / Disallow: /_astro/ Disallow: /_website/ Disallow: /_vercel/ +Disallow: /payment/ + +User-agent: GPTBot +Allow: / + +User-agent: OAI-SearchBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: Claude-SearchBot +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Google-Extended +Allow: / Sitemap: https://comfy.org/sitemap-index.xml diff --git a/apps/website/scripts/generate-models.ts b/apps/website/scripts/generate-models.ts new file mode 100644 index 0000000000..a546f608be --- /dev/null +++ b/apps/website/scripts/generate-models.ts @@ -0,0 +1,396 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WORKFLOW_TEMPLATES_BASE = + 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates' + +const TEMPLATES_DIR = fileURLToPath( + new URL('../../../../workflow_templates/templates', import.meta.url) +) + +const QUANT_SUFFIXES = [ + '_fp8_e4m3fn_scaled', + '_fp8_e4m3fn', + '_fp8_scaled', + '_fp4_mixed', + '_fp8mixed', + '_fp8', + '_fp16', + '_fp4', + '_bf16', + '_int8' +] + +interface RawModel { + name: string + url: string + directory: string +} + +interface ModelData { + url: string + directory: string + templates: Set + firstTemplate?: string +} + +interface OutputModel { + slug: string + name: string + huggingFaceUrl: string + directory: string + workflowCount: number + displayName: string + docsUrl?: string + thumbnailUrl?: string + canonicalSlug?: string +} + +// Maps api_*.json filename prefix to a canonical display name and slug. +// Add entries here as new partner integrations land in workflow_templates. +const API_PROVIDER_MAP: Record = { + nano: { name: 'Nano Banana', slug: 'nano-banana' }, + kling: { name: 'Kling AI', slug: 'kling-ai' }, + kling2: { name: 'Kling AI', slug: 'kling-ai' }, + meshy: { name: 'Meshy AI', slug: 'meshy-ai' }, + luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' }, + runway: { name: 'Runway', slug: 'runway' }, + vidu: { name: 'Vidu', slug: 'vidu' }, + bfl: { name: 'Flux (API)', slug: 'flux-api' }, + grok: { name: 'Grok Image', slug: 'grok-image' }, + stability: { name: 'Stability AI', slug: 'stability-ai' }, + bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' }, + bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' }, + google: { name: 'Gemini Image', slug: 'gemini-image' }, + hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' }, + ideogram: { name: 'Ideogram', slug: 'ideogram' }, + pixverse: { name: 'Pixverse', slug: 'pixverse' }, + rodin: { name: 'Rodin 3D', slug: 'rodin-3d' }, + magnific: { name: 'Magnific AI', slug: 'magnific-ai' }, + bria: { name: 'Bria AI', slug: 'bria-ai' }, + tripo: { name: 'Tripo 3D', slug: 'tripo-3d' }, + tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' }, + hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' }, + recraft: { name: 'Recraft', slug: 'recraft' }, + topaz: { name: 'Topaz Labs', slug: 'topaz-labs' }, + moonvalley: { name: 'Moonvalley', slug: 'moonvalley' }, + ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' }, + openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' }, + wan: { name: 'Wan (API)', slug: 'wan-api' }, + wan2: { name: 'Wan (API)', slug: 'wan-api' }, + veo2: { name: 'Veo 2', slug: 'veo-2' }, + veo3: { name: 'Veo 3', slug: 'veo-3' }, + flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' }, + wavespeed: { name: 'Wavespeed', slug: 'wavespeed' }, + wavespped: { name: 'Wavespeed', slug: 'wavespeed' } +} + +function stripExt(name: string): string { + return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '') +} + +function stripQuant(base: string): string { + for (const suffix of QUANT_SUFFIXES) { + if (base.endsWith(suffix)) return base.slice(0, -suffix.length) + } + return base +} + +function makeSlug(name: string): string { + const base = stripExt(name) + return base + .toLowerCase() + .replace(/[_.]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function makeDisplayName(name: string): string { + const base = stripExt(name) + return base + .split(/[_-]/) + .map((part) => { + if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase() + if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part + if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part + return part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(' ') +} + +function extractModels( + obj: unknown, + templateName: string, + models: Map +): void { + if (obj === null || typeof obj !== 'object') return + + if (Array.isArray(obj)) { + for (const item of obj) extractModels(item, templateName, models) + return + } + + const record = obj as Record + + if (Array.isArray(record['models'])) { + for (const m of record['models'] as unknown[]) { + if (m === null || typeof m !== 'object' || Array.isArray(m)) continue + const model = m as Record + if (typeof model['name'] !== 'string') continue + + const name = model['name'] + const url = typeof model['url'] === 'string' ? model['url'] : '' + const directory = + typeof model['directory'] === 'string' ? model['directory'] : '' + + if (!models.has(name)) { + models.set(name, { + url, + directory, + templates: new Set(), + firstTemplate: templateName + }) + } + models.get(name)!.templates.add(templateName) + } + } + + for (const value of Object.values(record)) { + extractModels(value, templateName, models) + } +} + +interface ApiModelData { + slug: string + name: string + directory: 'partner_nodes' + templateCount: number +} + +function extractApiModels(files: string[]): ApiModelData[] { + const counts = new Map() + for (const file of files) { + if (!file.startsWith('api_')) continue + const prefix = file.slice(4).split('_')[0] + const entry = API_PROVIDER_MAP[prefix] + if (!entry) continue + counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1) + } + return [...counts.entries()].map(([slug, count]) => { + const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)! + return { + slug, + name: found.name, + directory: 'partner_nodes' as const, + templateCount: count + } + }) +} + +// Reads all locale index.json files to build a map of +// raw model filename → tutorialUrl. Index entries name the template file; +// that file's embedded model objects give the actual filenames. +function buildTutorialUrlMap(templatesDir: string): Map { + const map = new Map() + const indexFiles = readdirSync(templatesDir).filter( + (f) => + f.startsWith('index') && + f.endsWith('.json') && + !f.includes('schema') && + !f.includes('logo') + ) + // Collect template-name → tutorialUrl from all locale indexes (first wins) + const templateTutorialMap = new Map() + const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')] + for (const file of sorted) { + let data: unknown + try { + data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8')) + } catch { + continue + } + if (!Array.isArray(data)) continue + for (const cat of data as unknown[]) { + if (typeof cat !== 'object' || cat === null) continue + const templates = (cat as Record)['templates'] + if (!Array.isArray(templates)) continue + for (const t of templates) { + if (typeof t !== 'object' || t === null) continue + const entry = t as Record + const tutorialUrl = + typeof entry['tutorialUrl'] === 'string' + ? entry['tutorialUrl'] + : undefined + const templateName = + typeof entry['name'] === 'string' ? entry['name'] : undefined + if ( + tutorialUrl && + templateName && + !templateTutorialMap.has(templateName) + ) { + templateTutorialMap.set(templateName, tutorialUrl) + } + } + } + } + + // For each template with a tutorialUrl, open the template file and map + // every embedded model filename to that tutorialUrl + for (const [templateName, tutorialUrl] of templateTutorialMap) { + const filePath = join(templatesDir, `${templateName}.json`) + let data: unknown + try { + data = JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + continue + } + + function extractModelNames(obj: unknown): void { + if (obj === null || typeof obj !== 'object') return + if (Array.isArray(obj)) { + for (const item of obj) extractModelNames(item) + return + } + const record = obj as Record + if (Array.isArray(record['models'])) { + for (const m of record['models'] as unknown[]) { + if (m === null || typeof m !== 'object' || Array.isArray(m)) continue + const model = m as Record + if (typeof model['name'] === 'string' && !map.has(model['name'])) { + map.set(model['name'], tutorialUrl) + } + } + } + for (const value of Object.values(record)) { + extractModelNames(value) + } + } + + extractModelNames(data) + } + + return map +} + +function templateThumbnailUrl( + firstTemplate: string | undefined, + templatesDir: string +): string | undefined { + if (!firstTemplate) return undefined + const base = firstTemplate.replace(/\.json$/, '') + const localPath = join(templatesDir, `${base}-1.webp`) + if (!existsSync(localPath)) return undefined + return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp` +} + +function run(): void { + const models = new Map() + + const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json')) + + for (const file of files) { + const filePath = join(TEMPLATES_DIR, file) + try { + const raw = readFileSync(filePath, 'utf8') + const data: unknown = JSON.parse(raw) + extractModels(data, file, models) + } catch (error) { + throw new Error( + `Failed to parse ${file}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + } + + const apiModels = extractApiModels(files) + const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR) + + const sorted = [...models.entries()].sort( + ([, a], [, b]) => b.templates.size - a.templates.size + ) + + // Build quant convergence map + const groups = new Map>() + for (const [name, data] of sorted) { + const base = stripExt(name) + const canonicalBase = stripQuant(base) + if (!groups.has(canonicalBase)) groups.set(canonicalBase, []) + groups.get(canonicalBase)!.push([name, data]) + } + + const canonicalMap = new Map() + for (const members of groups.values()) { + if (members.length > 1) { + const membersSorted = [...members].sort( + ([, a], [, b]) => b.templates.size - a.templates.size + ) + const canonicalName = membersSorted[0][0] + canonicalMap.set(canonicalName, null) + for (const [name] of membersSorted.slice(1)) { + canonicalMap.set(name, canonicalName) + } + } else { + canonicalMap.set(members[0][0], null) + } + } + + const output: OutputModel[] = sorted.map(([name, data]) => { + const canonicalRaw = canonicalMap.get(name) ?? null + const result: OutputModel = { + slug: makeSlug(name), + name, + huggingFaceUrl: data.url, + directory: data.directory, + workflowCount: data.templates.size, + displayName: makeDisplayName(name) + } + const docsUrl = tutorialUrlMap.get(name) + if (docsUrl) result.docsUrl = docsUrl + const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR) + if (thumb) result.thumbnailUrl = thumb + if (canonicalRaw !== null) { + result.canonicalSlug = makeSlug(canonicalRaw) + } + return result + }) + + const apiOutput: OutputModel[] = apiModels + .sort((a, b) => b.templateCount - a.templateCount) + .map((m) => ({ + slug: m.slug, + name: m.name, + huggingFaceUrl: '', + directory: m.directory, + workflowCount: m.templateCount, + displayName: m.name + })) + + const combined = [...apiOutput, ...output] + + const withThumbs = combined.filter((m) => m.thumbnailUrl).length + process.stdout.write( + ` ${withThumbs}/${combined.length} models have thumbnails\n` + ) + + const defaultOut = join( + fileURLToPath(new URL('.', import.meta.url)), + '../src/config/generated-models.json' + ) + const outputArg = process.argv[2] ?? defaultOut + const json = JSON.stringify(combined, null, 2) + '\n' + + writeFileSync(outputArg, json, 'utf8') + process.stdout.write( + `Written ${combined.length} models ` + + `(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n` + ) +} + +try { + run() +} catch (err) { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +} diff --git a/apps/website/scripts/refresh-cloud-nodes-snapshot.ts b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts new file mode 100644 index 0000000000..4817bb2168 --- /dev/null +++ b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts @@ -0,0 +1,32 @@ +import { renameSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes' + +const snapshotPath = fileURLToPath( + new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url) +) +const tempPath = `${snapshotPath}.tmp` + +const outcome = await fetchCloudNodesForBuild() + +if (outcome.status !== 'fresh') { + const reason = 'reason' in outcome ? outcome.reason : '(none)' + console.error( + `Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}` + ) + process.exit(1) +} + +const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n' + +writeFileSync(tempPath, serialized, 'utf8') +renameSync(tempPath, snapshotPath) + +const totalNodes = outcome.snapshot.packs.reduce( + (n, pack) => n + pack.nodes.length, + 0 +) +process.stdout.write( + `Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n` +) diff --git a/apps/website/src/components/careers/RolesSection.vue b/apps/website/src/components/careers/RolesSection.vue index db6053a3e1..f57a400f02 100644 --- a/apps/website/src/components/careers/RolesSection.vue +++ b/apps/website/src/components/careers/RolesSection.vue @@ -1,10 +1,13 @@