Compare commits
129 Commits
fix/load-a
...
fix/node-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461c71670b | ||
|
|
c47f89ed28 | ||
|
|
feb8555013 | ||
|
|
06f7e13957 | ||
|
|
e31d98b743 | ||
|
|
736f0fa416 | ||
|
|
d8d8aeb99e | ||
|
|
f4868894fa | ||
|
|
a96c61d2c2 | ||
|
|
c420cff4f0 | ||
|
|
8ccfe852b4 | ||
|
|
e2ef041170 | ||
|
|
a4952e9c45 | ||
|
|
64c852bf82 | ||
|
|
697087fdca | ||
|
|
a7218b2922 | ||
|
|
39a774bc15 | ||
|
|
74dedc314d | ||
|
|
5ae747e7c1 | ||
|
|
21069d54e7 | ||
|
|
8a456043e8 | ||
|
|
585e6f87fa | ||
|
|
4781775a78 | ||
|
|
74a48ab2aa | ||
|
|
0875e2f50f | ||
|
|
63442d2fb0 | ||
|
|
bb6f00dc68 | ||
|
|
11fc09220c | ||
|
|
16f4f3f3ed | ||
|
|
4c5a49860c | ||
|
|
db5e8961e0 | ||
|
|
9f9fa60137 | ||
|
|
7131c274f3 | ||
|
|
82556f02a9 | ||
|
|
e34548724d | ||
|
|
fcdc08fb27 | ||
|
|
f1626acb61 | ||
|
|
5640eb7d92 | ||
|
|
9447a1f5d6 | ||
|
|
1054503b4e | ||
|
|
9652871aaf | ||
|
|
1280d4110d | ||
|
|
871715113c | ||
|
|
a9f9afd062 | ||
|
|
79d0e6dc69 | ||
|
|
de866a15d2 | ||
|
|
31a33a0ba2 | ||
|
|
d73f8e1beb | ||
|
|
4e85537b15 | ||
|
|
b5ddc70233 | ||
|
|
48d928fc9e | ||
|
|
10b0350d01 | ||
|
|
5167a511ce | ||
|
|
4bdf67ca21 | ||
|
|
e119383072 | ||
|
|
f4bf169b2f | ||
|
|
07d5bd50f6 | ||
|
|
c318cc4c14 | ||
|
|
bfabf128ce | ||
|
|
6a8f3ef1a1 | ||
|
|
649b9b3fe3 | ||
|
|
d82bce90ea | ||
|
|
91e429a62f | ||
|
|
21edbd3ee5 | ||
|
|
f5363e4028 | ||
|
|
4337b8d6c6 | ||
|
|
113a2b5d92 | ||
|
|
bb84dd202d | ||
|
|
e5c8d26061 | ||
|
|
37ff065061 | ||
|
|
5fe31e63ec | ||
|
|
7206dea2d6 | ||
|
|
8f07468fdd | ||
|
|
8b9eb5f60d | ||
|
|
8c93567019 | ||
|
|
ffda940e5a | ||
|
|
84f77e7675 | ||
|
|
adf81fcd73 | ||
|
|
c85a15547b | ||
|
|
c602dce375 | ||
|
|
34b1799b21 | ||
|
|
bacb5570c8 | ||
|
|
8b53d5c807 | ||
|
|
39ce4a23cc | ||
|
|
ef477d0381 | ||
|
|
37c6ddfcd9 | ||
|
|
55c42ee484 | ||
|
|
b04db536a1 | ||
|
|
975d6a360d | ||
|
|
7e137d880b | ||
|
|
8db6fb7733 | ||
|
|
7c2c59b9fb | ||
|
|
2f7f3c4e56 | ||
|
|
4c00d39ade | ||
|
|
f1fc5fa9b3 | ||
|
|
c111fb7758 | ||
|
|
65655ba35f | ||
|
|
852d77159e | ||
|
|
b61029b9da | ||
|
|
0a62ea0b2c | ||
|
|
cccc0944a0 | ||
|
|
aadec87bff | ||
|
|
f5088d6cfb | ||
|
|
fd7ce3a852 | ||
|
|
08a2f8ae15 | ||
|
|
7b9f24f515 | ||
|
|
faed80e99a | ||
|
|
b129d64c5d | ||
|
|
4c9b83a224 | ||
|
|
e973efb44a | ||
|
|
9ecb100d11 | ||
|
|
dc3e455993 | ||
|
|
76006fca52 | ||
|
|
d2792cfac6 | ||
|
|
a786825093 | ||
|
|
b0f3b69bda | ||
|
|
d11a0f6c5e | ||
|
|
f97c38e6ee | ||
|
|
e89a0f96cd | ||
|
|
12989e8b63 | ||
|
|
c084605e4d | ||
|
|
b368a865cf | ||
|
|
1d7a5b9e0b | ||
|
|
fbcd36d355 | ||
|
|
e594164b71 | ||
|
|
245f840e7c | ||
|
|
240b54419b | ||
|
|
d9020b7fbe | ||
|
|
c4272ef1da |
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
|
||||
"Bash(pnpx vitest run \"draftCacheV2.property\")",
|
||||
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
150
.claude/skills/backport-management/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: backport-management
|
||||
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
|
||||
---
|
||||
|
||||
# Backport Management
|
||||
|
||||
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
|
||||
|
||||
## 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. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
5. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ----------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
|
||||
|
||||
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
|
||||
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
|
||||
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
### Use `gh api` for Labels — NOT `gh pr edit`
|
||||
|
||||
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
```
|
||||
|
||||
### Automation Over-Reports Conflicts
|
||||
|
||||
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
|
||||
|
||||
### Never Skip Based on Conflict File Count
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
|
||||
| Type | Symptom | Resolution |
|
||||
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
```python
|
||||
# Accept theirs for content conflicts
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### Escalation Triggers (Flag for Human)
|
||||
|
||||
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
|
||||
- **Core type definition changes** → requires human judgment
|
||||
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
|
||||
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
|
||||
|
||||
## Auto-Skip Categories
|
||||
|
||||
Skip these without discussion:
|
||||
|
||||
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
|
||||
- **CI/tooling changes** — Not user-facing
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
```bash
|
||||
# Fetch latest state of target branch
|
||||
git fetch origin TARGET_BRANCH
|
||||
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
|
||||
|
||||
- Backport bug fixes as they merge to main (same day or next day)
|
||||
- Use the automation labels immediately after merge
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
|
||||
```
|
||||
|
||||
### Manual Worktree Cherry-Pick (conflict fallback)
|
||||
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts, push, create PR, merge
|
||||
```
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
68
.claude/skills/backport-management/reference/analysis.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Analysis & Decision Framework
|
||||
|
||||
## Categorization
|
||||
|
||||
| Category | Criteria | Action |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
|
||||
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
|
||||
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
|
||||
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
|
||||
|
||||
### MUST vs SHOULD Decision Guide
|
||||
|
||||
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
|
||||
|
||||
- **Yes** → MUST. The fix addresses broken behavior.
|
||||
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
|
||||
- **No, and it's cosmetic or internal** → SKIP.
|
||||
|
||||
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
|
||||
|
||||
## Branch Scope Filtering
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
## Features Not on Stable Branches
|
||||
|
||||
Check before backporting — these don't exist on older branches:
|
||||
|
||||
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
|
||||
- **GLSLShader** — not on core/1.40
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
## 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.
|
||||
|
||||
## Revert Pairs
|
||||
|
||||
If PR A is reverted by PR B:
|
||||
|
||||
- Skip BOTH A and B
|
||||
- If a fixed version exists (PR C), backport only C
|
||||
|
||||
## Dependency Analysis
|
||||
|
||||
```bash
|
||||
# Find other PRs that touched the same files
|
||||
gh pr view $PR --json files --jq '.files[].path' | while read f; do
|
||||
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Present decisions.md before execution. Include:
|
||||
|
||||
1. All MUST/SHOULD/SKIP categorizations with rationale
|
||||
2. Questions for human (feature existence, scope, deps)
|
||||
3. Estimated effort per branch
|
||||
42
.claude/skills/backport-management/reference/discovery.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
|
||||
```bash
|
||||
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
|
||||
```
|
||||
|
||||
Parse bot messages for PRs flagged "Might need backport" per release version.
|
||||
|
||||
## Source 2: Git Log Gap Analysis
|
||||
|
||||
```bash
|
||||
# Count gap
|
||||
git log --oneline origin/TARGET..origin/main | wc -l
|
||||
|
||||
# List gap commits
|
||||
git log --oneline origin/TARGET..origin/main
|
||||
|
||||
# Check if a PR is already on target
|
||||
git log --oneline origin/TARGET --grep="#PR_NUMBER"
|
||||
|
||||
# Check for existing backport PRs
|
||||
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
|
||||
```
|
||||
|
||||
## Source 3: GitHub PR Details
|
||||
|
||||
```bash
|
||||
# Get merge commit SHA
|
||||
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
|
||||
|
||||
# Get files changed
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Category | Flagged by Bot? | Decision |
|
||||
150
.claude/skills/backport-management/reference/execution.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Execution Workflow
|
||||
|
||||
## Per-Branch Execution Order
|
||||
|
||||
1. Smallest gap first (validation run)
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
# Add labels to all candidates for a target branch
|
||||
for pr in $PR_LIST; do
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/backport-TARGET
|
||||
|
||||
for PR in ${CONFLICT_PRS[@]}; do
|
||||
# Refresh target ref so each branch is based on current HEAD
|
||||
git fetch origin TARGET_BRANCH
|
||||
git checkout origin/TARGET_BRANCH
|
||||
|
||||
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
git push origin backport-$PR-to-TARGET
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
cd -
|
||||
git worktree remove /tmp/backport-TARGET --force
|
||||
```
|
||||
|
||||
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
|
||||
|
||||
## Step 4: Wave Verification
|
||||
|
||||
After completing all PRs in a wave for a target branch:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
### 1. Content Conflicts (accept theirs)
|
||||
|
||||
```python
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### 2. Modify/Delete (two cases!)
|
||||
|
||||
```bash
|
||||
# Case A: PR introduces NEW files not on target → keep them
|
||||
git add $FILE
|
||||
|
||||
# Case B: Target REMOVED files the PR modifies → drop them
|
||||
git rm $FILE
|
||||
```
|
||||
|
||||
### 3. Binary Files (snapshots)
|
||||
|
||||
```bash
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
|
||||
```
|
||||
|
||||
## Merge Conflicts After Other Merges
|
||||
|
||||
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Automation reports more conflicts than reality** — `cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
|
||||
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
|
||||
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
|
||||
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
|
||||
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
|
||||
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
96
.claude/skills/backport-management/reference/logging.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Logging & Session Reports
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave:
|
||||
|
||||
```markdown
|
||||
## Wave N Verification — TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
|
||||
```markdown
|
||||
# Backport Session Report
|
||||
|
||||
## Summary
|
||||
|
||||
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
|
||||
| ------ | ---------- | ------ | ------- | -------- | ---- |
|
||||
|
||||
## Deferred Items (Needs Human)
|
||||
|
||||
| PR# | Title | Branch | Issue |
|
||||
|
||||
## Conflict Resolutions Requiring Review
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------- | ----- |
|
||||
| Auto success rate | X% |
|
||||
| Manual resolution rate | X% |
|
||||
| Overall clean rate | X% |
|
||||
| Wave verification pass rate | X% |
|
||||
|
||||
## Process Recommendations
|
||||
|
||||
- Were there clusters of related PRs that should have been backported together?
|
||||
- Any PRs that should have been backported sooner (continuous backporting candidates)?
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverable: Visual Summary
|
||||
|
||||
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph branch1["☁️ cloud/X.XX — N PRs"]
|
||||
C1["#XXXX title"]
|
||||
C2["#XXXX title"]
|
||||
end
|
||||
|
||||
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
|
||||
M1["#XXXX title"]
|
||||
end
|
||||
|
||||
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
|
||||
S1["#XXXX-#XXXX N auto-merged"]
|
||||
S2["#XXXX-#XXXX N manual picks"]
|
||||
end
|
||||
|
||||
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
|
||||
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
|
||||
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
|
||||
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
|
||||
```
|
||||
|
||||
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
|
||||
|
||||
## Files to Track
|
||||
|
||||
- `candidate_list.md` — all candidates per branch
|
||||
- `decisions.md` — MUST/SHOULD/SKIP with rationale
|
||||
- `wave-plan.md` — execution order
|
||||
- `execution-log.md` — real-time status
|
||||
- `backport-session-report.md` — final summary
|
||||
|
||||
All in `~/temp/backport-session/`.
|
||||
179
.claude/skills/perf-fix-with-proof/SKILL.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: perf-fix-with-proof
|
||||
description: 'Ships performance fixes with CI-proven improvement using stacked PRs. PR1 adds a @perf test (establishes baseline on main), PR2 adds the fix (CI shows delta). Use when implementing a perf optimization and wanting to prove it in CI.'
|
||||
---
|
||||
|
||||
# Performance Fix with Proof
|
||||
|
||||
Ships perf fixes as two stacked PRs so CI automatically proves the improvement.
|
||||
|
||||
## Why Two PRs
|
||||
|
||||
The `ci-perf-report.yaml` workflow compares PR metrics against the **base branch baseline**. If you add a new `@perf` test in the same PR as the fix, that test doesn't exist on main yet — no baseline, no delta, no proof. Stacking solves this:
|
||||
|
||||
1. **PR1 (test-only)** — adds the `@perf` test that exercises the bottleneck. Merges to main. CI runs it on main → baseline established.
|
||||
2. **PR2 (fix)** — adds the optimization. CI runs the same test → compares against PR1's baseline → delta shows improvement.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Create the test branch
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/test-<name> origin/main
|
||||
```
|
||||
|
||||
### Step 2: Write the `@perf` test
|
||||
|
||||
Add a test to `browser_tests/tests/performance.spec.ts` (or a new file with `@perf` tag). The test should stress the specific bottleneck.
|
||||
|
||||
**Test structure:**
|
||||
|
||||
```typescript
|
||||
test('<descriptive name>', async ({ comfyPage }) => {
|
||||
// 1. Load a workflow that exercises the bottleneck
|
||||
await comfyPage.workflow.loadWorkflow('<workflow>')
|
||||
|
||||
// 2. Start measuring
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// 3. Perform the action that triggers the bottleneck (at scale)
|
||||
for (let i = 0; i < N; i++) {
|
||||
// ... stress the hot path ...
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// 4. Stop measuring and record
|
||||
const m = await comfyPage.perf.stopMeasuring('<metric-name>')
|
||||
recordMeasurement(m)
|
||||
console.log(`<name>: ${m.styleRecalcs} recalcs, ${m.layouts} layouts`)
|
||||
})
|
||||
```
|
||||
|
||||
**Available metrics** (from `PerformanceHelper`):
|
||||
|
||||
- `m.styleRecalcs` / `m.styleRecalcDurationMs` — style recalculation count and time
|
||||
- `m.layouts` / `m.layoutDurationMs` — forced layout count and time
|
||||
- `m.taskDurationMs` — total main-thread JS execution time
|
||||
- `m.heapDeltaBytes` — memory pressure delta
|
||||
|
||||
**Key helpers** (from `ComfyPage`):
|
||||
|
||||
- `comfyPage.perf.startMeasuring()` / `.stopMeasuring(name)` — CDP metrics capture
|
||||
- `comfyPage.nextFrame()` — wait one animation frame
|
||||
- `comfyPage.workflow.loadWorkflow(name)` — load a test workflow from `browser_tests/assets/`
|
||||
- `comfyPage.canvas` — the canvas locator
|
||||
- `comfyPage.page.mouse.move(x, y)` — mouse interaction
|
||||
|
||||
### Step 3: Add test workflow asset (if needed)
|
||||
|
||||
If the bottleneck needs a specific workflow (e.g., 50+ nodes, many DOM widgets), add it to `browser_tests/assets/`. Keep it minimal — only the structure needed to trigger the bottleneck.
|
||||
|
||||
### Step 4: Verify locally
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=performance --grep "<test name>"
|
||||
```
|
||||
|
||||
Confirm the test runs and produces reasonable metric values.
|
||||
|
||||
### Step 5: Create PR1 (test-only)
|
||||
|
||||
```bash
|
||||
pnpm typecheck:browser
|
||||
pnpm lint
|
||||
git add browser_tests/
|
||||
git commit -m "test: add perf test for <bottleneck description>"
|
||||
git push -u origin perf/test-<name>
|
||||
gh pr create --title "test: add perf test for <bottleneck>" \
|
||||
--body "Adds a @perf test to establish a baseline for <bottleneck>.
|
||||
|
||||
This is PR 1 of 2. The fix will follow in a separate PR once this baseline is established on main.
|
||||
|
||||
## What
|
||||
Adds \`<test-name>\` to the performance test suite measuring <metric> during <action>.
|
||||
|
||||
## Why
|
||||
Needed to prove the improvement from the upcoming fix for backlog item #<N>." \
|
||||
--base main
|
||||
```
|
||||
|
||||
### Step 6: Get PR1 merged
|
||||
|
||||
Once PR1 merges, CI runs the test on main → baseline artifact saved.
|
||||
|
||||
### Step 7: Create PR2 (fix) on top of main
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/fix-<name> origin/main
|
||||
```
|
||||
|
||||
Implement the fix. The `@perf` test from PR1 is now on main and will run automatically. CI will:
|
||||
|
||||
1. Run the test on the PR branch
|
||||
2. Download the baseline from main (which includes PR1's test results)
|
||||
3. Post a PR comment showing the delta
|
||||
|
||||
### Step 8: Verify the improvement shows in CI
|
||||
|
||||
The `ci-perf-report.yaml` posts a comment like:
|
||||
|
||||
```markdown
|
||||
## ⚡ Performance Report
|
||||
|
||||
| Metric | Baseline | PR (n=3) | Δ | Sig |
|
||||
| --------------------- | -------- | -------- | ---- | --- |
|
||||
| <name>: style recalcs | 450 | 12 | -97% | 🟢 |
|
||||
```
|
||||
|
||||
If Δ is negative for the target metric, the fix is proven.
|
||||
|
||||
## Test Design Guidelines
|
||||
|
||||
1. **Stress the specific bottleneck** — don't measure everything, isolate the hot path
|
||||
2. **Use enough iterations** — the test should run long enough that the metric difference is clear (100+ frames for idle tests, 50+ interactions for event tests)
|
||||
3. **Keep it deterministic** — avoid timing-dependent assertions; measure counts not durations when possible
|
||||
4. **Match the backlog entry** — reference the backlog item number in the test name or PR description
|
||||
|
||||
## Examples
|
||||
|
||||
**Testing DOM widget reactive mutations (backlog #8):**
|
||||
|
||||
```typescript
|
||||
test('DOM widget positioning recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
// Idle for 120 frames — DOM widgets update position every frame
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('dom-widget-idle')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
**Testing measureText caching (backlog #4):**
|
||||
|
||||
```typescript
|
||||
test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-workflow-50-nodes')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('text-rendering-50-nodes')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
| Backlog | `docs/perf/BACKLOG.md` (local only, not committed) |
|
||||
| Playbook | `docs/perf/PLAYBOOK.md` (local only, not committed) |
|
||||
221
.claude/skills/red-green-fix/SKILL.md
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: red-green-fix
|
||||
description: 'Bug fix workflow that proves test validity with a red-then-green CI sequence. Commits a failing test first (CI red), then the minimal fix (CI green). Use when fixing a bug, writing a regression test, or when asked to prove a fix works.'
|
||||
---
|
||||
|
||||
# Red-Green Fix
|
||||
|
||||
Fixes bugs as two commits so CI automatically proves the test catches the bug.
|
||||
|
||||
## Why Two Commits
|
||||
|
||||
If you commit the test and fix together, the test always passes — reviewers cannot tell whether the test actually detects the bug or is a no-op. Splitting into two commits creates a verifiable CI trail:
|
||||
|
||||
1. **Commit 1 (test-only)** — adds a test that exercises the bug. CI runs it → test fails → red X.
|
||||
2. **Commit 2 (fix)** — adds the minimal fix. CI runs the same test → test passes → green check.
|
||||
|
||||
The red-then-green sequence in the commit history proves the test is valid.
|
||||
|
||||
## Input
|
||||
|
||||
The user provides a bug description as an argument. If no description is given, ask the user to describe the bug before proceeding.
|
||||
|
||||
Bug description: $ARGUMENTS
|
||||
|
||||
## Step 0 — Setup
|
||||
|
||||
Create an isolated branch from main:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git checkout -b fix/<bug-name> origin/main
|
||||
```
|
||||
|
||||
## Step 1 — Red: Failing Test Only
|
||||
|
||||
Write a test that reproduces the bug. **Do NOT write any fix code.**
|
||||
|
||||
### Choosing the Test Framework
|
||||
|
||||
| Bug type | Framework | File location |
|
||||
| --------------------------------- | ---------- | ------------------------------- |
|
||||
| Logic, utils, stores, composables | Vitest | `src/**/*.test.ts` (colocated) |
|
||||
| UI interaction, canvas, workflows | Playwright | `browser_tests/tests/*.spec.ts` |
|
||||
|
||||
For Playwright tests, follow the `/writing-playwright-tests` skill for patterns, fixtures, and tags.
|
||||
|
||||
### Rules
|
||||
|
||||
- The test MUST fail against the current codebase (this is the whole point)
|
||||
- Do NOT modify any source code outside of test files
|
||||
- Do NOT include any fix, workaround, or behavioral change
|
||||
- Do NOT add unrelated tests or refactor existing tests
|
||||
- Keep the test minimal — only what is needed to reproduce the bug
|
||||
- Avoid common anti-patterns — see `reference/testing-anti-patterns.md`
|
||||
|
||||
### Vitest Example
|
||||
|
||||
```typescript
|
||||
// src/utils/pathUtil.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveModelPath } from './pathUtil'
|
||||
|
||||
describe('resolveModelPath', () => {
|
||||
it('handles absolute paths from folder_paths API', () => {
|
||||
const result = resolveModelPath(
|
||||
'/absolute/models',
|
||||
'/absolute/models/checkpoints'
|
||||
)
|
||||
expect(result).toBe('/absolute/models/checkpoints')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Playwright Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Model Download', { tag: ['@smoke'] }, () => {
|
||||
test('downloads model when path is absolute', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing-model')
|
||||
const downloadBtn = comfyPage.page.getByTestId('download-model-button')
|
||||
await downloadBtn.click()
|
||||
await expect(comfyPage.page.getByText('Download complete')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Verify Locally First
|
||||
|
||||
Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit -- <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
```
|
||||
|
||||
If the test passes locally, it does not reproduce the bug — revisit your test before pushing.
|
||||
|
||||
### Quality Checks and Commit
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
|
||||
git add <test-files-only>
|
||||
git commit -m "test: add failing test for <concise bug description>"
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
### Verify CI Failure
|
||||
|
||||
```bash
|
||||
gh run list --branch $(git branch --show-current) --limit 1
|
||||
```
|
||||
|
||||
**STOP HERE.** Inform the user of the CI status and wait for confirmation before proceeding to Step 2.
|
||||
|
||||
- If CI passes: the test does not catch the bug. Revisit the test.
|
||||
- If CI fails for unrelated reasons: investigate and fix the test setup, not the bug.
|
||||
- If CI fails because the test correctly catches the bug: proceed to Step 2.
|
||||
|
||||
## Step 2 — Green: Minimal Fix
|
||||
|
||||
Write the minimum code change needed to make the failing test pass.
|
||||
|
||||
### Rules
|
||||
|
||||
- Do NOT modify, weaken, or delete the test from Step 1 — it is immutable. If the test needs changes, restart from Step 1 and re-prove the red.
|
||||
- Do NOT add new tests (tests were finalized in Step 1)
|
||||
- Do NOT refactor, clean up, or make "drive-by" improvements
|
||||
- Do NOT modify code unrelated to the bug
|
||||
- The fix should be the smallest correct change
|
||||
|
||||
### Quality Checks and Commit
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format
|
||||
|
||||
git add <fix-files-only>
|
||||
git commit -m "fix: <concise bug description>"
|
||||
git push
|
||||
```
|
||||
|
||||
### Verify CI Pass
|
||||
|
||||
```bash
|
||||
gh run list --branch $(git branch --show-current) --limit 1
|
||||
```
|
||||
|
||||
- If CI passes: the fix is verified. Proceed to PR creation.
|
||||
- If CI fails: investigate and fix. Do NOT change the test from Step 1.
|
||||
|
||||
## Step 3 — Open Pull Request
|
||||
|
||||
```bash
|
||||
gh pr create --title "fix: <description>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
<Brief explanation of the bug and root cause>
|
||||
|
||||
- Fixes #<issue-number>
|
||||
|
||||
## Red-Green Verification
|
||||
|
||||
| Commit | CI Status | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `test: ...` | :red_circle: Red | Proves the test catches the bug |
|
||||
| `fix: ...` | :green_circle: Green | Proves the fix resolves the bug |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] CI red on test-only commit
|
||||
- [ ] CI green on fix commit
|
||||
- [ ] Added/updated E2E regression under `browser_tests/` or explained why not applicable
|
||||
- [ ] Manual verification (if applicable)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### CI fails on test commit for unrelated reasons
|
||||
|
||||
Lint, typecheck, or other tests may fail — not just your new test. Check the CI logs carefully. If the failure is unrelated, fix it in a separate commit before the `test:` commit so the red X is clearly attributable to your test.
|
||||
|
||||
### Test passes when it should fail
|
||||
|
||||
The bug may only manifest under specific conditions (e.g., Windows paths, external model directories, certain workflow structures). Make sure your test setup matches the actual bug scenario. Check that you're not accidentally testing the happy path.
|
||||
|
||||
### Flaky Playwright tests
|
||||
|
||||
If your e2e test is intermittent, it doesn't prove anything. Use retrying assertions (`toBeVisible`, `toHaveText`) instead of `waitForTimeout`. See the `/writing-playwright-tests` skill for anti-patterns.
|
||||
|
||||
### Pre-existing CI failures on main
|
||||
|
||||
If main itself is red, branch from the last green commit or fix the pre-existing failure first. A red-green proof is meaningless if the baseline is already red.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| --------------------- | -------------------------------------------------- |
|
||||
| Unit test framework | Vitest (`src/**/*.test.ts`) |
|
||||
| E2E test framework | Playwright (`browser_tests/tests/*.spec.ts`) |
|
||||
| E2E fixtures | `browser_tests/fixtures/` |
|
||||
| E2E assets | `browser_tests/assets/` |
|
||||
| Playwright skill | `.claude/skills/writing-playwright-tests/SKILL.md` |
|
||||
| Unit CI | `.github/workflows/ci-tests-unit.yaml` |
|
||||
| E2E CI | `.github/workflows/ci-tests-e2e.yaml` |
|
||||
| Lint CI | `.github/workflows/ci-lint-format.yaml` |
|
||||
| Testing anti-patterns | `reference/testing-anti-patterns.md` |
|
||||
| Related skill | `.claude/skills/perf-fix-with-proof/SKILL.md` |
|
||||
214
.claude/skills/red-green-fix/reference/testing-anti-patterns.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Testing Anti-Patterns for Red-Green Fixes
|
||||
|
||||
Common mistakes that undermine the red-green proof. Avoid these when writing the test commit (Step 1).
|
||||
|
||||
## Testing Implementation Details
|
||||
|
||||
Test observable behavior, not internal state.
|
||||
|
||||
**Bad** — coupling to internals:
|
||||
|
||||
```typescript
|
||||
it('uses cache internally', () => {
|
||||
const service = new UserService()
|
||||
service.getUser(1)
|
||||
expect(service._cache.has(1)).toBe(true) // Implementation detail
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — testing through the public interface:
|
||||
|
||||
```typescript
|
||||
it('returns same user on repeated calls', async () => {
|
||||
const service = new UserService()
|
||||
const user1 = await service.getUser(1)
|
||||
const user2 = await service.getUser(1)
|
||||
expect(user1).toBe(user2) // Behavior, not implementation
|
||||
})
|
||||
```
|
||||
|
||||
Why this matters for red-green: if your test is coupled to internals, a valid fix that changes the implementation may break the test — even though the bug is fixed. The green commit should only require changing source code, not rewriting the test.
|
||||
|
||||
## Assertion-Free Tests
|
||||
|
||||
Every test must assert something meaningful. A test without assertions always passes — it cannot produce the red X needed in Step 1.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('processes the download', () => {
|
||||
processDownload('/models/checkpoints', 'model.safetensors')
|
||||
// No expect()!
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it('processes the download to correct path', () => {
|
||||
const result = processDownload('/models/checkpoints', 'model.safetensors')
|
||||
expect(result.savePath).toBe('/models/checkpoints/model.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
## Over-Mocking
|
||||
|
||||
Mock only system boundaries (network, filesystem, Electron APIs). If you mock the module under test, you are testing your mocks — the test will not detect the real bug.
|
||||
|
||||
**Bad** — mocking everything:
|
||||
|
||||
```typescript
|
||||
vi.mock('./pathResolver')
|
||||
vi.mock('./validator')
|
||||
vi.mock('./downloader')
|
||||
|
||||
it('downloads model', () => {
|
||||
// This only tests that mocks were called, not that the bug exists
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — mock only the boundary:
|
||||
|
||||
```typescript
|
||||
vi.mock('./electronAPI') // Boundary: Electron IPC
|
||||
|
||||
it('resolves absolute path correctly', () => {
|
||||
const result = resolveModelPath('/root/models', '/root/models/checkpoints')
|
||||
expect(result).toBe('/root/models/checkpoints')
|
||||
})
|
||||
```
|
||||
|
||||
See also: [Don't Mock What You Don't Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
|
||||
|
||||
## Giant Tests
|
||||
|
||||
A test that covers the entire flow makes it hard to pinpoint which part catches the bug. Keep it focused — one concept per test.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('full model download flow', async () => {
|
||||
// 80 lines: load workflow, open dialog, select model,
|
||||
// click download, verify path, check progress, confirm completion
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it('resolves absolute savePath without nesting under modelsDirectory', () => {
|
||||
const result = getLocalSavePath(
|
||||
'/models',
|
||||
'/models/checkpoints',
|
||||
'file.safetensors'
|
||||
)
|
||||
expect(result).toBe('/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
If the bug is in path resolution, test path resolution — not the entire download flow.
|
||||
|
||||
## Test Duplication
|
||||
|
||||
Duplicated test code hides what actually differs between cases. Use parameterized tests.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('resolves checkpoints path', () => {
|
||||
expect(resolve('/models', '/models/checkpoints', 'a.safetensors')).toBe(
|
||||
'/models/checkpoints/a.safetensors'
|
||||
)
|
||||
})
|
||||
it('resolves loras path', () => {
|
||||
expect(resolve('/models', '/models/loras', 'b.safetensors')).toBe(
|
||||
'/models/loras/b.safetensors'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it.each([
|
||||
['/models/checkpoints', 'a.safetensors', '/models/checkpoints/a.safetensors'],
|
||||
['/models/loras', 'b.safetensors', '/models/loras/b.safetensors']
|
||||
])('resolves %s/%s to %s', (dir, file, expected) => {
|
||||
expect(resolve('/models', dir, file)).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
## Flaky Tests
|
||||
|
||||
A flaky test cannot prove anything — it may show red for reasons unrelated to the bug, or green despite the bug still existing.
|
||||
|
||||
**Common causes in this codebase:**
|
||||
|
||||
| Cause | Fix |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| Missing `nextFrame()` after canvas ops | Add `await comfyPage.nextFrame()` |
|
||||
| `waitForTimeout` instead of assertions | Use `toBeVisible()`, `toHaveText()` |
|
||||
| Shared state between tests | Isolate with `afterEach` / `beforeEach` |
|
||||
| Timing-dependent logic | Use `expect.poll()` or `toPass()` |
|
||||
|
||||
## Gaming the Red-Green Process
|
||||
|
||||
These are ways the red-green proof gets invalidated during Step 2 (the fix commit). The test from Step 1 is immutable — if any of these happen, restart from Step 1.
|
||||
|
||||
**Weakening the assertion to make it pass:**
|
||||
|
||||
```typescript
|
||||
// Step 1 (red) — strict assertion
|
||||
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
|
||||
|
||||
// Step 2 (green) — weakened to pass without a real fix
|
||||
expect(result).toBeDefined() // This proves nothing
|
||||
```
|
||||
|
||||
**Updating snapshots to bless the bug:**
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit -- --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
**Adding mocks in Step 2 that hide the failure:**
|
||||
|
||||
```typescript
|
||||
// Step 2 adds a mock that didn't exist in Step 1
|
||||
vi.mock('./pathResolver', () => ({
|
||||
resolve: () => '/expected/path' // Hardcoded to pass
|
||||
}))
|
||||
```
|
||||
|
||||
Step 2 should only change source code — not test infrastructure.
|
||||
|
||||
## Testing the Happy Path Only
|
||||
|
||||
The red-green pattern specifically requires the test to exercise the **broken path**. If you only test the case that already works, the test will pass (green) on Step 1 — defeating the purpose.
|
||||
|
||||
**Bad** — testing the default case that works:
|
||||
|
||||
```typescript
|
||||
it('downloads to default models directory', () => {
|
||||
// This already works — it won't produce a red X
|
||||
const result = resolve('/models', 'checkpoints', 'file.safetensors')
|
||||
expect(result).toBe('/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — testing the case that is actually broken:
|
||||
|
||||
```typescript
|
||||
it('downloads to external models directory configured via extra_model_paths', () => {
|
||||
// This is the broken case — absolute path from folder_paths API
|
||||
const result = resolve(
|
||||
'/models',
|
||||
'/external/drive/models/checkpoints',
|
||||
'file.safetensors'
|
||||
)
|
||||
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
361
.claude/skills/ticket-intake/SKILL.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: ticket-intake
|
||||
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
|
||||
---
|
||||
|
||||
# Ticket Intake
|
||||
|
||||
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
|
||||
|
||||
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
|
||||
|
||||
## Supported Sources
|
||||
|
||||
| Source | URL Pattern | Provider File |
|
||||
| ------ | --------------------------------------------------- | --------------------- |
|
||||
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
|
||||
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
When given a ticket URL:
|
||||
|
||||
1. **Detect source type** from URL pattern
|
||||
2. **Load provider-specific logic** from `providers/` directory
|
||||
3. Fetch ticket content via appropriate API
|
||||
4. Extract and normalize properties to common schema
|
||||
5. **Register ticket in pipeline API** ← MANDATORY
|
||||
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
|
||||
7. **Run verification script** to confirm API registration
|
||||
8. Output summary and handoff to `research-orchestrator`
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses the **production API** by default. No configuration needed for read operations.
|
||||
|
||||
**Defaults (no setup required):**
|
||||
|
||||
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
|
||||
- Read-only endpoints at `/public/*` require no authentication
|
||||
|
||||
**For write operations** (transitions, creating tickets), set:
|
||||
|
||||
```bash
|
||||
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
|
||||
```
|
||||
|
||||
**Optional (for local working artifacts):**
|
||||
|
||||
```bash
|
||||
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
|
||||
```
|
||||
|
||||
## Mandatory API Calls (Execute ALL Three)
|
||||
|
||||
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
|
||||
|
||||
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
|
||||
|
||||
### Call 1: Create Ticket
|
||||
|
||||
```bash
|
||||
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
|
||||
API_KEY="${PIPELINE_API_KEY}"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"notion_page_id": "NOTION_PAGE_UUID_HERE",
|
||||
"title": "TICKET_TITLE_HERE",
|
||||
"source": "notion",
|
||||
"metadata": {
|
||||
"description": "DESCRIPTION_HERE",
|
||||
"priority": "High",
|
||||
"labels": [],
|
||||
"acceptanceCriteria": []
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Save the returned `id` — you need it for the next two calls.
|
||||
|
||||
### Call 2: Transition to RESEARCH
|
||||
|
||||
```bash
|
||||
TICKET_ID="id-from-step-1"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"to_state": "RESEARCH",
|
||||
"reason": "Intake complete, starting research"
|
||||
}'
|
||||
```
|
||||
|
||||
### Call 3: Queue Source Update
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${API_URL}/v1/sync/queue" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"ticket_id": "TICKET_ID_HERE",
|
||||
"action": "update_status",
|
||||
"payload": { "status": "In Progress" },
|
||||
"priority": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
|
||||
|
||||
### TypeScript Equivalent (if using pipeline client)
|
||||
|
||||
```typescript
|
||||
import { PipelineClient } from '@pipeline/client'
|
||||
|
||||
const client = new PipelineClient({
|
||||
apiUrl:
|
||||
process.env.PIPELINE_API_URL ||
|
||||
'https://api-gateway-856475788601.us-central1.run.app',
|
||||
agentId: process.env.AGENT_ID!
|
||||
})
|
||||
|
||||
const ticket = await client.createTicket({
|
||||
notion_page_id: pageId,
|
||||
title: ticketTitle,
|
||||
source: 'notion',
|
||||
metadata: { description, priority, labels, acceptanceCriteria }
|
||||
})
|
||||
|
||||
await client.transitionState(
|
||||
ticket.id,
|
||||
'RESEARCH',
|
||||
'Intake complete, starting research'
|
||||
)
|
||||
|
||||
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Detect Source Type
|
||||
|
||||
Parse the URL to determine source:
|
||||
|
||||
```javascript
|
||||
if (url.includes('notion.so')) {
|
||||
source = 'notion'
|
||||
// Load providers/notion.md
|
||||
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
|
||||
source = 'github'
|
||||
// Load providers/github.md
|
||||
} else {
|
||||
// Error: Unsupported URL format
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Load Provider and Fetch Data
|
||||
|
||||
Read the appropriate provider file for source-specific instructions:
|
||||
|
||||
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
|
||||
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
|
||||
|
||||
Follow the provider's instructions for:
|
||||
|
||||
- Fetching content
|
||||
- Extracting properties
|
||||
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
|
||||
|
||||
### Step 3: Normalize to Common Schema
|
||||
|
||||
All providers must extract normalized ticket data following `schema.md`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc12345",
|
||||
"url": "https://...",
|
||||
"source": "notion | github",
|
||||
"title": "Ticket title",
|
||||
"description": "Full description",
|
||||
"status": "Not Started",
|
||||
"assignee": "username",
|
||||
"priority": "High",
|
||||
"area": "UI",
|
||||
"labels": ["bug", "frontend"],
|
||||
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
|
||||
"fetchedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
|
||||
|
||||
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
|
||||
|
||||
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
|
||||
|
||||
1. `createTicket()` → save the returned ticket ID
|
||||
2. `transitionState(id, 'RESEARCH')` → confirm state changed
|
||||
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
|
||||
|
||||
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
|
||||
|
||||
### Step 5: Run Verification Script
|
||||
|
||||
After making the API calls, run the verification script to confirm everything worked:
|
||||
|
||||
```bash
|
||||
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
|
||||
```
|
||||
|
||||
**If the script is not available locally**, verify manually via the public API:
|
||||
|
||||
```bash
|
||||
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"state": "RESEARCH",
|
||||
"title": "...",
|
||||
"notion_page_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
|
||||
|
||||
### Step 6: Output Summary and Handoff
|
||||
|
||||
Print a clear summary:
|
||||
|
||||
```markdown
|
||||
## Ticket Intake Complete
|
||||
|
||||
**Source:** Notion | GitHub
|
||||
**Title:** [Ticket title]
|
||||
**ID:** abc12345
|
||||
**Status:** In Progress (queued)
|
||||
**Priority:** High
|
||||
**Area:** UI
|
||||
|
||||
### Description
|
||||
|
||||
[Brief description or first 200 chars]
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
|
||||
### Links
|
||||
|
||||
- **Ticket:** [Original URL]
|
||||
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
|
||||
|
||||
### Pipeline
|
||||
|
||||
- **API Ticket ID:** abc12345
|
||||
- **State:** RESEARCH
|
||||
- **Verified:** ✅ (via verify-intake.sh or public API)
|
||||
```
|
||||
|
||||
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
|
||||
|
||||
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
|
||||
|
||||
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Unsupported URL
|
||||
|
||||
```
|
||||
❌ Unsupported ticket URL format.
|
||||
|
||||
Supported formats:
|
||||
- Notion: https://notion.so/... or https://www.notion.so/...
|
||||
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
|
||||
|
||||
Received: [provided URL]
|
||||
```
|
||||
|
||||
### Provider-Specific Errors
|
||||
|
||||
See individual provider files for source-specific error handling:
|
||||
|
||||
- `providers/notion.md` - Authentication, page not found
|
||||
- `providers/github.md` - Auth, rate limits, issue not found
|
||||
|
||||
### Missing Properties
|
||||
|
||||
Continue with available data and note what's missing:
|
||||
|
||||
```
|
||||
⚠️ Some properties unavailable:
|
||||
- Priority: not found (using default: Medium)
|
||||
- Area: not found
|
||||
|
||||
Proceeding with available data...
|
||||
```
|
||||
|
||||
### API Call Failures
|
||||
|
||||
```
|
||||
❌ Pipeline API call failed: {method} {endpoint}
|
||||
Status: {status}
|
||||
Error: {message}
|
||||
|
||||
Retrying once...
|
||||
|
||||
❌ Retry also failed. INTAKE IS INCOMPLETE.
|
||||
The ticket was NOT registered in the pipeline.
|
||||
Downstream skills will not work until this is fixed.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill focuses ONLY on intake — it does not do research
|
||||
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
|
||||
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
|
||||
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
|
||||
- Pipeline state is tracked via the API, not local files
|
||||
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
|
||||
- The `source` field in the ticket determines which research strategies to use
|
||||
|
||||
## API Client Reference
|
||||
|
||||
### Available Methods
|
||||
|
||||
| Method | Description |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
|
||||
| `getTicket(id)` | Retrieve a ticket by ID |
|
||||
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
|
||||
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
|
||||
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
|
||||
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
|
||||
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
|
||||
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
|
||||
|
||||
try {
|
||||
await client.createTicket({ ... });
|
||||
} catch (error) {
|
||||
if (error instanceof PipelineAPIError) {
|
||||
console.error(`API Error ${error.status}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
194
.claude/skills/ticket-intake/providers/github.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# GitHub Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from GitHub Issues.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://github.com/{owner}/{repo}/issues/{number}
|
||||
https://www.github.com/{owner}/{repo}/issues/{number}
|
||||
```
|
||||
|
||||
Extract: `owner`, `repo`, `issue_number` from URL.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Access to the repository
|
||||
|
||||
## Fetch Issue Content
|
||||
|
||||
Use `gh` CLI to fetch issue details:
|
||||
|
||||
```bash
|
||||
# Get issue details in JSON
|
||||
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
|
||||
|
||||
# Get comments separately if needed
|
||||
gh issue view {number} --repo {owner}/{repo} --comments
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Map GitHub issue fields to normalized ticket data (stored via API):
|
||||
|
||||
| GitHub Field | ticket.json Field | Notes |
|
||||
| ------------ | ----------------- | -------------------------- |
|
||||
| title | title | Direct mapping |
|
||||
| body | description | Issue body/description |
|
||||
| state | status | Map: open → "Not Started" |
|
||||
| labels | labels | Array of label names |
|
||||
| assignees | assignee | First assignee login |
|
||||
| author | author | Issue author login |
|
||||
| milestone | milestone | Milestone title if present |
|
||||
| comments | comments | Array of comment objects |
|
||||
| linkedPRs | linkedPRs | PRs linked to this issue |
|
||||
|
||||
### Priority Mapping
|
||||
|
||||
Infer priority from labels:
|
||||
|
||||
- `priority:critical`, `P0` → "Critical"
|
||||
- `priority:high`, `P1` → "High"
|
||||
- `priority:medium`, `P2` → "Medium"
|
||||
- `priority:low`, `P3` → "Low"
|
||||
- No priority label → "Medium" (default)
|
||||
|
||||
### Area Mapping
|
||||
|
||||
Infer area from labels:
|
||||
|
||||
- `area:ui`, `frontend`, `component:*` → "UI"
|
||||
- `area:api`, `backend` → "API"
|
||||
- `area:docs`, `documentation` → "Docs"
|
||||
- `bug`, `fix` → "Bug"
|
||||
- `enhancement`, `feature` → "Feature"
|
||||
|
||||
## Update Source
|
||||
|
||||
**For GitHub issues, update is optional but recommended.**
|
||||
|
||||
Add a comment to indicate work has started:
|
||||
|
||||
```bash
|
||||
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
|
||||
```
|
||||
|
||||
Optionally assign to self:
|
||||
|
||||
```bash
|
||||
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
|
||||
```
|
||||
|
||||
Log any updates via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
githubWrites: [
|
||||
...(ticket.metadata?.githubWrites || []),
|
||||
{
|
||||
action: 'comment',
|
||||
issueNumber: 123,
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## GitHub-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "github",
|
||||
"githubOwner": "Comfy-Org",
|
||||
"githubRepo": "ComfyUI_frontend",
|
||||
"githubIssueNumber": 123,
|
||||
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
|
||||
"labels": ["bug", "area:ui", "priority:high"],
|
||||
"linkedPRs": [456, 789],
|
||||
"dosuComment": "..." // Extracted Dosu bot analysis if present
|
||||
}
|
||||
```
|
||||
|
||||
## Dosu Bot Detection
|
||||
|
||||
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
|
||||
|
||||
```bash
|
||||
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
|
||||
```
|
||||
|
||||
Look for comments from:
|
||||
|
||||
- `dosu[bot]`
|
||||
- `dosu-bot`
|
||||
|
||||
Extract Dosu analysis which typically includes:
|
||||
|
||||
- Root cause analysis
|
||||
- Suggested files to modify
|
||||
- Related issues/PRs
|
||||
- Potential solutions
|
||||
|
||||
Store in ticket data via API:
|
||||
|
||||
```json
|
||||
{
|
||||
"dosuComment": {
|
||||
"found": true,
|
||||
"analysis": "...",
|
||||
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
|
||||
"relatedIssues": [100, 101]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extract Linked Issues/PRs
|
||||
|
||||
Parse issue body and comments for references:
|
||||
|
||||
- `#123` → Issue or PR reference
|
||||
- `fixes #123`, `closes #123` → Linked issue
|
||||
- `https://github.com/.../issues/123` → Full URL reference
|
||||
|
||||
Store in ticket data via API for research phase:
|
||||
|
||||
```json
|
||||
{
|
||||
"referencedIssues": [100, 101, 102],
|
||||
"referencedPRs": [200, 201]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ GitHub CLI not authenticated.
|
||||
Run: gh auth login
|
||||
```
|
||||
|
||||
### Issue Not Found
|
||||
|
||||
```
|
||||
❌ GitHub issue not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this repository
|
||||
- Run: gh auth status
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```
|
||||
⚠️ GitHub API rate limited.
|
||||
Wait a few minutes and try again.
|
||||
Check status: gh api rate_limit
|
||||
```
|
||||
202
.claude/skills/ticket-intake/providers/notion.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Notion Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from Notion.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://www.notion.so/workspace/Page-Title-abc123def456...
|
||||
https://notion.so/Page-Title-abc123def456...
|
||||
https://www.notion.so/abc123def456...
|
||||
```
|
||||
|
||||
Page ID is the 32-character hex string (with or without hyphens).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Notion MCP connected and authenticated
|
||||
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
|
||||
- Authenticate via `/mcp` command if prompted
|
||||
|
||||
## Fetch Ticket Content
|
||||
|
||||
Use `Notion:notion-fetch` with the page URL or ID:
|
||||
|
||||
```
|
||||
Fetch the full page content including all properties
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Extract these properties (names may vary):
|
||||
|
||||
| Property | Expected Name | Type |
|
||||
| ------------- | ------------------------- | ------------ |
|
||||
| Title | Name / Title | Title |
|
||||
| Status | Status | Select |
|
||||
| Assignee | Assignee / Assigned To | Person |
|
||||
| Description | - | Page content |
|
||||
| Slack Link | Slack Link / Slack Thread | URL |
|
||||
| GitHub PR | GitHub PR / PR Link | URL |
|
||||
| Priority | Priority | Select |
|
||||
| Area | Area / Category | Select |
|
||||
| Related Tasks | Related Tasks | Relation |
|
||||
|
||||
**If properties are missing**: Note what's unavailable and continue with available data.
|
||||
|
||||
## Update Source (REQUIRED)
|
||||
|
||||
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
|
||||
|
||||
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
|
||||
|
||||
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
|
||||
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
|
||||
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
|
||||
|
||||
Use `Notion:notion-update-page` to update the ticket:
|
||||
|
||||
1. **Status**: Set to "In Progress" (only valid from "Not Started")
|
||||
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
|
||||
|
||||
```json
|
||||
{
|
||||
"page_id": "{page_id_from_ticket}",
|
||||
"command": "update_properties",
|
||||
"properties": {
|
||||
"Status": "In Progress",
|
||||
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After the update succeeds**, log the write via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
notionWrites: [
|
||||
...(ticket.metadata?.notionWrites || []),
|
||||
{
|
||||
field: 'Status',
|
||||
value: 'In Progress',
|
||||
previousValue: 'Not Started',
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
If update fails, log with `success: false` and continue.
|
||||
|
||||
## Notion-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "notion",
|
||||
"notionPageId": "abc123def456...",
|
||||
"slackLink": "https://slack.com/...",
|
||||
"relatedTasks": ["page-id-1", "page-id-2"]
|
||||
}
|
||||
```
|
||||
|
||||
## Slack Thread Handling
|
||||
|
||||
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
|
||||
|
||||
### Slack URL Conversion
|
||||
|
||||
Notion stores Slack links in `slackMessage://` format:
|
||||
|
||||
```
|
||||
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
|
||||
```
|
||||
|
||||
Convert to browser-clickable format:
|
||||
|
||||
```
|
||||
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
|
||||
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
|
||||
|
||||
(Remove the dot from the last timestamp and prefix with `p`)
|
||||
|
||||
### Fetching Thread Content
|
||||
|
||||
Load the `slackdump` skill and use the **export-thread** workflow:
|
||||
|
||||
```bash
|
||||
# Export thread by URL
|
||||
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
|
||||
|
||||
# Or by colon notation (channel_id:thread_ts)
|
||||
slackdump dump CHANNEL_ID:THREAD_TS
|
||||
```
|
||||
|
||||
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
|
||||
|
||||
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
|
||||
|
||||
## Database Reference: Comfy Tasks
|
||||
|
||||
The "Comfy Tasks" database has these properties (verify via `notion-search`):
|
||||
|
||||
- **Status values**: Not Started, In Progress, In Review, Done
|
||||
- **Team assignment**: "Frontend Team" for unassigned tickets
|
||||
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
|
||||
|
||||
### Pipeline Owner Details
|
||||
|
||||
When assigning tickets, use these identifiers:
|
||||
|
||||
| Platform | Identifier |
|
||||
| --------------- | -------------------------------------- |
|
||||
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
|
||||
| Notion Name | Christian Byrne |
|
||||
| Notion Email | cbyrne@comfy.org |
|
||||
| Slack User ID | U087MJCDHHC |
|
||||
| GitHub Username | christian-byrne |
|
||||
|
||||
**To update Assignee**, use the Notion User ID (not name):
|
||||
|
||||
```
|
||||
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
|
||||
```
|
||||
|
||||
### Finding Active Tickets
|
||||
|
||||
To list your active tickets:
|
||||
|
||||
```
|
||||
Use Notion:notion-search for "Comfy Tasks"
|
||||
Filter by Assignee = current user OR Team = "Frontend Team"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ Notion authentication required.
|
||||
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
|
||||
Then authenticate via /mcp command.
|
||||
```
|
||||
|
||||
### Page Not Found
|
||||
|
||||
```
|
||||
❌ Notion page not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this page
|
||||
- Try re-authenticating via /mcp
|
||||
```
|
||||
81
.claude/skills/ticket-intake/schema.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Ticket Schema
|
||||
|
||||
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
|
||||
|
||||
## Ticket Data Schema
|
||||
|
||||
```json
|
||||
{
|
||||
// Required fields (all sources)
|
||||
"id": "string", // Unique identifier (short form)
|
||||
"url": "string", // Original URL
|
||||
"source": "notion | github", // Source type
|
||||
"title": "string", // Ticket title
|
||||
"description": "string", // Full description/body
|
||||
"fetchedAt": "ISO8601", // When ticket was fetched
|
||||
|
||||
// Common optional fields
|
||||
"status": "string", // Current status
|
||||
"assignee": "string", // Assigned user
|
||||
"priority": "string", // Priority level
|
||||
"area": "string", // Category/area
|
||||
"labels": ["string"], // Tags/labels
|
||||
"acceptanceCriteria": ["string"] // List of AC items
|
||||
|
||||
// Source-specific fields (see providers)
|
||||
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
|
||||
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
|
||||
}
|
||||
```
|
||||
|
||||
## Ticket State Schema (via API)
|
||||
|
||||
State is managed via the Pipeline API using `client.transitionState()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ticketId": "string",
|
||||
"state": "intake | research | planning | implementation | pr_created | done | failed",
|
||||
"stateChangedAt": "ISO8601",
|
||||
|
||||
// Timestamps tracked by API
|
||||
"createdAt": "ISO8601",
|
||||
"updatedAt": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
## Priority Normalization
|
||||
|
||||
All sources should normalize to these values:
|
||||
|
||||
| Normalized | Description |
|
||||
| ---------- | ------------------------- |
|
||||
| Critical | Production down, security |
|
||||
| High | Blocking work, urgent |
|
||||
| Medium | Normal priority (default) |
|
||||
| Low | Nice to have, backlog |
|
||||
|
||||
## Status Normalization
|
||||
|
||||
Pipeline tracks these statuses internally:
|
||||
|
||||
| Status | Description |
|
||||
| -------------- | ---------------------------- |
|
||||
| research | Gathering context |
|
||||
| planning | Creating implementation plan |
|
||||
| implementation | Writing code |
|
||||
| review | Code review in progress |
|
||||
| qa | Quality assurance |
|
||||
| done | PR merged or completed |
|
||||
|
||||
## ID Generation
|
||||
|
||||
IDs are generated by the API when creating tickets. For reference:
|
||||
|
||||
- **Notion**: First 8 characters of page ID
|
||||
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
|
||||
|
||||
Examples:
|
||||
|
||||
- Notion: `abc12345`
|
||||
- GitHub: `gh-comfy-org-frontend-123`
|
||||
143
.claude/skills/writing-storybook-stories/SKILL.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: writing-storybook-stories
|
||||
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
|
||||
---
|
||||
|
||||
# Write Storybook Stories for ComfyUI_frontend
|
||||
|
||||
## Workflow
|
||||
|
||||
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
|
||||
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
|
||||
3. Read nearby stories before writing anything.
|
||||
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
|
||||
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
|
||||
4. If a Figma link is provided, list the states you need to cover before writing stories.
|
||||
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
|
||||
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
|
||||
7. Run Storybook and validation checks before handing off.
|
||||
|
||||
## Match Local Conventions
|
||||
|
||||
- Copy the closest neighboring story instead of forcing one universal template.
|
||||
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
|
||||
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
|
||||
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
|
||||
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
|
||||
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
|
||||
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
|
||||
- Add decorators for realistic width or background context when the component needs it.
|
||||
|
||||
## Title Patterns
|
||||
|
||||
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
|
||||
|
||||
| Component area | Typical title pattern |
|
||||
| ------------------------------------------------------- | ------------------------------------ |
|
||||
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
|
||||
| `src/components/ui/input/Input.vue` | `Components/Input` |
|
||||
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
|
||||
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
|
||||
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
|
||||
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
|
||||
|
||||
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
|
||||
|
||||
## Common Story Shapes
|
||||
|
||||
### Stateful input or `v-model`
|
||||
|
||||
```typescript
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MyComponent },
|
||||
setup() {
|
||||
const { disabled, size } = toRefs(args)
|
||||
const value = ref('Hello world')
|
||||
return { value, disabled, size }
|
||||
},
|
||||
template:
|
||||
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Slot-driven content
|
||||
|
||||
```typescript
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
|
||||
argTypes: {
|
||||
default: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
default: 'Button'
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleButton: Story = {
|
||||
render: (args) => ({
|
||||
components: { Button },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<Button v-bind="args">{{ args.default }}</Button>'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Variants or edge cases grid
|
||||
|
||||
```typescript
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MyComponent },
|
||||
template: `
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<MyComponent />
|
||||
<MyComponent disabled />
|
||||
<MyComponent loading />
|
||||
<MyComponent invalid />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Figma Mapping
|
||||
|
||||
- Extract the named states from the design first.
|
||||
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
|
||||
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
|
||||
- Use pseudo-state parameters only if the addon is already configured in this repo.
|
||||
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
|
||||
|
||||
## Component-Specific Notes
|
||||
|
||||
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
|
||||
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
|
||||
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
|
||||
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
|
||||
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Read the component source and any supporting types or composables
|
||||
- [ ] Match the nearest local title pattern and story style
|
||||
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
|
||||
- [ ] Add focused stories for meaningful states
|
||||
- [ ] Add `tags: ['autodocs']`
|
||||
- [ ] Keep the story co-located with the component
|
||||
- [ ] Run `pnpm storybook`
|
||||
- [ ] Run `pnpm typecheck`
|
||||
- [ ] Run `pnpm lint`
|
||||
|
||||
## Avoid
|
||||
|
||||
- Do not guess props, emits, slots, or exposed methods.
|
||||
- Do not force one generic title convention across the repo.
|
||||
- Do not mutate Storybook args directly for `v-model` components.
|
||||
- Do not introduce `dark:` Tailwind variants in story wrappers.
|
||||
- Do not create barrel files.
|
||||
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: 'ComfyUI Storybook Stories'
|
||||
short_description: 'Write Vue Storybook stories for ComfyUI'
|
||||
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'
|
||||
@@ -3,6 +3,7 @@ issue_enrichment:
|
||||
enabled: true
|
||||
reviews:
|
||||
high_level_summary: false
|
||||
request_changes_workflow: true
|
||||
auto_review:
|
||||
drafts: true
|
||||
ignore_title_keywords:
|
||||
@@ -12,3 +13,18 @@ reviews:
|
||||
- comfy-pr-bot
|
||||
- github-actions
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
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`), 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
2
.gitattributes
vendored
@@ -3,4 +3,6 @@
|
||||
|
||||
# Generated files
|
||||
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
|
||||
packages/ingest-types/src/types.gen.ts linguist-generated=true
|
||||
packages/ingest-types/src/zod.gen.ts linguist-generated=true
|
||||
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
|
||||
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -19,7 +19,7 @@ runs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: './pnpm-lock.yaml'
|
||||
|
||||
|
||||
29
.github/workflows/ci-lint-format.yaml
vendored
@@ -61,6 +61,22 @@ jobs:
|
||||
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
|
||||
git push
|
||||
|
||||
- name: Fail for fork PRs with unfixed lint/format issues
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: |
|
||||
echo "::error::Linting/formatting issues found. Since this PR is from a fork, auto-fix cannot be applied automatically."
|
||||
echo ""
|
||||
echo "Please run these commands locally and push the changes:"
|
||||
echo " pnpm lint:fix"
|
||||
echo " pnpm stylelint:fix"
|
||||
echo " pnpm format"
|
||||
echo ""
|
||||
echo "Or set up pre-commit hooks to automatically format on every commit:"
|
||||
echo " pnpm prepare"
|
||||
echo ""
|
||||
echo "See CONTRIBUTING.md for more details."
|
||||
exit 1
|
||||
|
||||
- name: Final validation
|
||||
run: |
|
||||
pnpm lint
|
||||
@@ -84,16 +100,3 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
|
||||
})
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\npnpm prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\npnpm lint:fix\npnpm format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
|
||||
})
|
||||
|
||||
48
.github/workflows/ci-perf-report.yaml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: perf-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -26,12 +26,15 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
@@ -68,3 +71,44 @@ jobs:
|
||||
with:
|
||||
name: perf-meta
|
||||
path: temp/perf-meta/
|
||||
|
||||
- name: Save perf baseline to perf-data branch
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
|
||||
cp test-results/perf-metrics.json /tmp/perf-metrics.json
|
||||
|
||||
git fetch origin perf-data || {
|
||||
echo "Creating perf-data branch"
|
||||
git checkout --orphan perf-data
|
||||
git rm -rf . 2>/dev/null || true
|
||||
echo "# Performance Baselines" > README.md
|
||||
mkdir -p baselines
|
||||
git add README.md baselines
|
||||
git commit -m "Initialize perf-data branch"
|
||||
git push origin perf-data
|
||||
git fetch origin perf-data
|
||||
}
|
||||
|
||||
git worktree add /tmp/perf-data origin/perf-data
|
||||
|
||||
TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
SHA=$(echo "${{ github.sha }}" | cut -c1-8)
|
||||
mkdir -p /tmp/perf-data/baselines
|
||||
cp /tmp/perf-metrics.json "/tmp/perf-data/baselines/perf-${TIMESTAMP}-${SHA}.json"
|
||||
|
||||
# Keep only last 20 baselines
|
||||
cd /tmp/perf-data
|
||||
ls -t baselines/perf-*.json 2>/dev/null | tail -n +21 | xargs -r rm
|
||||
|
||||
git -C /tmp/perf-data add baselines/
|
||||
git -C /tmp/perf-data commit -m "perf: add baseline for ${SHA}" || echo "No changes to commit"
|
||||
git -C /tmp/perf-data push origin HEAD:perf-data
|
||||
|
||||
git worktree remove /tmp/perf-data --force 2>/dev/null || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
25
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -138,6 +140,29 @@ jobs:
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
npx wrangler@^4.0.0 pages deploy storybook-static \
|
||||
--project-name=comfy-storybook \
|
||||
--branch=main
|
||||
|
||||
# Update comment with Chromatic URLs for version-bump branches
|
||||
update-comment-with-chromatic:
|
||||
needs: [chromatic-deployment, deploy-and-comment]
|
||||
|
||||
14
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -46,18 +46,30 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
ACTION: ${{ github.event.action }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
|
||||
run: |
|
||||
if [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
REF="${PR_HEAD_SHA}"
|
||||
BRANCH="${PR_HEAD_REF}"
|
||||
|
||||
# Derive variant from all PR labels (default to cpu for frontend-only previews)
|
||||
VARIANT="cpu"
|
||||
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
|
||||
else
|
||||
REF="${GITHUB_SHA}"
|
||||
BRANCH="${GITHUB_REF_NAME}"
|
||||
PR_NUMBER=""
|
||||
VARIANT=""
|
||||
fi
|
||||
payload="$(jq -nc \
|
||||
--arg ref "${REF}" \
|
||||
--arg branch "${BRANCH}" \
|
||||
'{ref: $ref, branch: $branch}')"
|
||||
--arg pr_number "${PR_NUMBER}" \
|
||||
--arg variant "${VARIANT}" \
|
||||
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
|
||||
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch to cloud repo
|
||||
|
||||
39
.github/workflows/cloud-dispatch-cleanup.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
|
||||
# frontend PR with a preview label is closed or has its preview label
|
||||
# removed. The cloud repo handles the actual environment teardown.
|
||||
#
|
||||
# This is fire-and-forget — it does NOT wait for the cloud workflow to
|
||||
# complete. Status is visible in the cloud repo's Actions tab.
|
||||
|
||||
name: Cloud Frontend Preview Cleanup Dispatch
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Only dispatch when:
|
||||
# - PR closed AND had a preview label
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
((github.event.action == 'closed' &&
|
||||
(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 == 'unlabeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch to cloud repo
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
|
||||
repository: Comfy-Org/cloud
|
||||
event-type: frontend-preview-cleanup
|
||||
client-payload: >-
|
||||
{"pr_number": "${{ github.event.pull_request.number }}"}
|
||||
46
.github/workflows/pr-perf-report.yaml
vendored
@@ -10,6 +10,7 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
@@ -73,7 +74,28 @@ jobs:
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Check if results are still current
|
||||
id: sha-check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const runSha = context.payload.workflow_run.head_sha;
|
||||
const currentSha = pr.head.sha;
|
||||
if (runSha !== currentSha) {
|
||||
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
|
||||
core.setOutput('stale', 'true');
|
||||
} else {
|
||||
core.setOutput('stale', 'false');
|
||||
}
|
||||
|
||||
- name: Download PR perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
@@ -81,6 +103,7 @@ jobs:
|
||||
path: test-results/
|
||||
|
||||
- name: Download baseline perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
@@ -90,10 +113,33 @@ jobs:
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Load historical baselines from perf-data branch
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p temp/perf-history
|
||||
|
||||
git fetch origin perf-data 2>/dev/null || {
|
||||
echo "perf-data branch not found, skipping historical data"
|
||||
exit 0
|
||||
}
|
||||
|
||||
INDEX=0
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
|
||||
DIR="temp/perf-history/$INDEX"
|
||||
mkdir -p "$DIR"
|
||||
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
|
||||
INDEX=$((INDEX + 1))
|
||||
done
|
||||
|
||||
echo "Loaded $INDEX historical baselines"
|
||||
|
||||
- name: Generate perf report
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
|
||||
1
.gitignore
vendored
@@ -26,6 +26,7 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
document.documentElement.classList.remove('dark-theme')
|
||||
document.body.classList.remove('dark-theme')
|
||||
}
|
||||
document.body.classList.add('[&_*]:!font-inter')
|
||||
document.body.classList.add('font-inter')
|
||||
|
||||
return Story(context.args, context)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (see `.nvmrc`, currently v24) and pnpm
|
||||
- Node.js (see `.nvmrc` for the required version) and pnpm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
||||
|
||||
@@ -87,6 +87,10 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
|
||||
> ⚠️ IMPORTANT:
|
||||
> The dev server will NOT load JavaScript extensions from custom nodes. Only core extensions (built into the frontend) will be loaded. This is because the shim system that allows custom node JavaScript to import frontend modules only works in production builds. Python custom nodes still function normally. See [Extension Development Guide](docs/extensions/development.md) for details and workarounds. And See [Extension Overview](docs/extensions/README.md) for extensions overview.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run into issues during development (e.g. `pnpm dev` hanging, TypeScript errors after pulling, lock file conflicts), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common fixes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
368
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
|
||||
## Quick Diagnostic Flowchart
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
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]
|
||||
B -->|Other| F[See FAQ below]
|
||||
|
||||
Q --> R{oxlint or ESLint?}
|
||||
R -->|oxlint| S[Check .oxlintrc.json<br/>and run pnpm lint:fix]
|
||||
R -->|ESLint| T[Check eslint.config.ts<br/>and run pnpm lint:fix]
|
||||
S --> L
|
||||
T --> L
|
||||
|
||||
C --> G{Tried quick fixes?}
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
D --> M[Run: pnpm build]
|
||||
M --> N{Build succeeds?}
|
||||
N -->|No| O[Check error messages<br/>in FAQ]
|
||||
N -->|Yes| L
|
||||
|
||||
E --> H
|
||||
|
||||
F --> P[Search FAQ or<br/>ask in Discord]
|
||||
```
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
**Solutions (try in order):**
|
||||
|
||||
1. **First attempt - Reinstall dependencies:**
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
```
|
||||
|
||||
2. **Second attempt - Clean build cache:**
|
||||
|
||||
```bash
|
||||
pnpm clean
|
||||
```
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
#### Q: Port conflicts - "Address already in use"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Error: `EADDRINUSE` or "port already in use"
|
||||
- Dev server fails to start
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Find and kill the process using the port:**
|
||||
|
||||
```bash
|
||||
# On Linux/Mac
|
||||
lsof -ti:5173 | xargs kill -9
|
||||
|
||||
# On Windows
|
||||
netstat -ano | findstr :5173
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
2. **Use a different port** by adding a `port` option to the `server` block in `vite.config.mts`:
|
||||
```ts
|
||||
server: {
|
||||
port: 3000,
|
||||
// ...existing config
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Build and Type Issues
|
||||
|
||||
#### Q: TypeScript errors after pulling latest changes
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Type errors in files you didn't modify
|
||||
- "Cannot find module" errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Rebuild TypeScript references:**
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2. **Clean and reinstall:**
|
||||
|
||||
```bash
|
||||
pnpm clean && pnpm i
|
||||
```
|
||||
|
||||
3. **Restart your IDE's TypeScript server**
|
||||
- VS Code: `Cmd/Ctrl + Shift + P` → "TypeScript: Restart TS Server"
|
||||
|
||||
---
|
||||
|
||||
#### Q: "Workspace not found" or monorepo errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- pnpm can't find workspace packages
|
||||
- Import errors between packages
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Verify you're in the project root:**
|
||||
|
||||
```bash
|
||||
pwd # Should be in ComfyUI_frontend/
|
||||
```
|
||||
|
||||
2. **Rebuild workspace:**
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Linting Issues (oxlint)
|
||||
|
||||
#### Q: `eslint-disable` comment isn't suppressing an oxlint rule
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- `// eslint-disable-next-line rule-name` has no effect
|
||||
- Lint error persists despite the disable comment
|
||||
|
||||
**Solution:**
|
||||
|
||||
oxlint has its own disable syntax. Use `oxlint-disable` instead:
|
||||
|
||||
```ts
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log('debug')
|
||||
```
|
||||
|
||||
Check whether the rule is enforced by oxlint (in `.oxlintrc.json`) or ESLint (in `eslint.config.ts`) to pick the right disable comment.
|
||||
|
||||
---
|
||||
|
||||
#### Q: New lint errors after pulling/upgrading oxlint
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Lint errors in files you didn't change
|
||||
- Rules you haven't seen before (e.g. `no-immediate-mutation`, `prefer-optional-chain`)
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Run the auto-fixer first:**
|
||||
|
||||
```bash
|
||||
pnpm lint:fix
|
||||
```
|
||||
|
||||
2. **Review changes carefully** — some oxlint auto-fixes can produce incorrect code. Check the diff before committing.
|
||||
|
||||
3. **If a rule seems wrong**, check `.oxlintrc.json` to see if it should be disabled or configured differently.
|
||||
|
||||
**Why this happens:** oxlint version bumps often enable new rules by default.
|
||||
|
||||
---
|
||||
|
||||
#### Q: oxlint fails with TypeScript errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- `pnpm oxlint` or `pnpm lint` fails with type-related errors
|
||||
- Errors mention type resolution or missing type information
|
||||
|
||||
**Solution:**
|
||||
|
||||
oxlint runs with `--type-aware` in this project, which requires valid TypeScript compilation. Fix the TS errors first:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # Identify TS errors
|
||||
pnpm build # Or do a full build
|
||||
pnpm lint # Then re-run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Q: Duplicate lint errors from both oxlint and ESLint
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Same violation reported twice
|
||||
- Conflicting auto-fix suggestions
|
||||
|
||||
**Solution:**
|
||||
|
||||
The project uses `eslint-plugin-oxlint` to automatically disable ESLint rules that oxlint already covers (see `eslint.config.ts`). If you see duplicates:
|
||||
|
||||
1. Ensure `.oxlintrc.json` is up to date after adding new oxlint rules
|
||||
2. Run `pnpm lint` (which runs oxlint then ESLint in sequence) rather than running them individually
|
||||
|
||||
---
|
||||
|
||||
### Dependency and Package Issues
|
||||
|
||||
#### Q: "Package not found" after adding a dependency
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Module not found after `pnpm add`
|
||||
- Import errors for newly installed packages
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Ensure you installed in the correct workspace** (see `pnpm-workspace.yaml` for available workspaces):
|
||||
|
||||
```bash
|
||||
# Example: install in a specific workspace
|
||||
pnpm --filter <workspace-name> add <package>
|
||||
```
|
||||
|
||||
2. **Clear pnpm cache:**
|
||||
```bash
|
||||
pnpm store prune
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Q: Lock file conflicts after merge/rebase
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Git conflicts in `pnpm-lock.yaml`
|
||||
- Dependency resolution errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Regenerate lock file:**
|
||||
|
||||
```bash
|
||||
rm pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Or accept upstream lock file:**
|
||||
```bash
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Testing Issues
|
||||
|
||||
#### Q: Tests fail locally but pass in CI
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Flaky tests
|
||||
- Different results between local and CI
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Run tests in CI mode:**
|
||||
|
||||
```bash
|
||||
CI=true pnpm test:unit
|
||||
```
|
||||
|
||||
2. **Clear test cache:**
|
||||
|
||||
```bash
|
||||
pnpm test:unit --no-cache
|
||||
```
|
||||
|
||||
3. **Check Node version matches CI** (see `.nvmrc` for the required version):
|
||||
```bash
|
||||
node --version
|
||||
nvm use # If using nvm — reads .nvmrc automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Git and Branch Issues
|
||||
|
||||
#### Q: Changes from another branch appearing in my branch
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Uncommitted changes not related to your work
|
||||
- Dirty working directory
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Stash and reinstall:**
|
||||
|
||||
```bash
|
||||
git stash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Check for untracked files:**
|
||||
```bash
|
||||
git status
|
||||
git clean -fd # Careful: removes untracked files!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
1. **Search existing issues:** [GitHub Issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)
|
||||
2. **Ask the community:** [Discord](https://discord.com/invite/comfyorg) (navigate to the `#dev-frontend` channel)
|
||||
3. **Create a new issue:** Include:
|
||||
- Your OS and Node version (`node --version`)
|
||||
- Steps to reproduce
|
||||
- Full error message
|
||||
- What you've already tried
|
||||
|
||||
## Contributing to This Guide
|
||||
|
||||
Found a solution to a common problem? Please:
|
||||
|
||||
1. Open a PR to add it to this guide
|
||||
2. Follow the FAQ format above
|
||||
3. Include the symptoms, solutions, and why it happens
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-10
|
||||
@@ -27,7 +27,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
|
||||
Ensure you have the Node.js version specified in `.nvmrc` installed.
|
||||
Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
|
||||
4876
browser_tests/assets/large-graph-workflow.json
Normal file
47
browser_tests/assets/nodes/load_image_with_ksampler.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": [500, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [791.59912109375, 386.13336181640625],
|
||||
"size": [140, 26],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
|
||||
@@ -431,9 +431,9 @@ export const comfyPageFixture = base.extend<{
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
// Browser tests should opt into missing-model warnings explicitly so
|
||||
// workflows do not render differently based on models present on disk.
|
||||
'Comfy.Workflow.ShowMissingModelsWarning': false
|
||||
// Disable errors tab to prevent missing model detection from
|
||||
// rendering error indicators on nodes during unrelated tests.
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -8,6 +8,10 @@ interface PerfSnapshot {
|
||||
TaskDuration: number
|
||||
JSHeapUsedSize: number
|
||||
Timestamp: number
|
||||
Nodes: number
|
||||
JSHeapTotalSize: number
|
||||
ScriptDuration: number
|
||||
JSEventListeners: number
|
||||
}
|
||||
|
||||
export interface PerfMeasurement {
|
||||
@@ -19,6 +23,12 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
eventListeners: number
|
||||
totalBlockingTimeMs: number
|
||||
frameDurationMs: number
|
||||
}
|
||||
|
||||
export class PerformanceHelper {
|
||||
@@ -59,16 +69,100 @@ export class PerformanceHelper {
|
||||
LayoutDuration: get('LayoutDuration'),
|
||||
TaskDuration: get('TaskDuration'),
|
||||
JSHeapUsedSize: get('JSHeapUsedSize'),
|
||||
Timestamp: get('Timestamp')
|
||||
Timestamp: get('Timestamp'),
|
||||
Nodes: get('Nodes'),
|
||||
JSHeapTotalSize: get('JSHeapTotalSize'),
|
||||
ScriptDuration: get('ScriptDuration'),
|
||||
JSEventListeners: get('JSEventListeners')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect longtask entries from PerformanceObserver and compute TBT.
|
||||
* TBT = sum of (duration - 50ms) for every task longer than 50ms.
|
||||
*/
|
||||
private async collectTBT(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
const state = (window as unknown as Record<string, unknown>)
|
||||
.__perfLongtaskState as
|
||||
| { observer: PerformanceObserver; tbtMs: number }
|
||||
| undefined
|
||||
if (!state) return 0
|
||||
|
||||
// Flush any queued-but-undelivered entries into our accumulator
|
||||
for (const entry of state.observer.takeRecords()) {
|
||||
if (entry.duration > 50) state.tbtMs += entry.duration - 50
|
||||
}
|
||||
const result = state.tbtMs
|
||||
state.tbtMs = 0
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure average frame duration via rAF timing over a sample window.
|
||||
* Returns average ms per frame (lower = better, 16.67 = 60fps).
|
||||
*/
|
||||
private async measureFrameDuration(sampleFrames = 10): Promise<number> {
|
||||
return this.page.evaluate((frames) => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(0), 5000)
|
||||
const timestamps: number[] = []
|
||||
let count = 0
|
||||
function tick(ts: number) {
|
||||
timestamps.push(ts)
|
||||
count++
|
||||
if (count <= frames) {
|
||||
requestAnimationFrame(tick)
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
if (timestamps.length < 2) {
|
||||
resolve(0)
|
||||
return
|
||||
}
|
||||
const total = timestamps[timestamps.length - 1] - timestamps[0]
|
||||
resolve(total / (timestamps.length - 1))
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
}, sampleFrames)
|
||||
}
|
||||
|
||||
async startMeasuring(): Promise<void> {
|
||||
if (this.snapshot) {
|
||||
throw new Error(
|
||||
'Measurement already in progress — call stopMeasuring() first'
|
||||
)
|
||||
}
|
||||
// Install longtask observer if not already present, then reset the
|
||||
// accumulator so old longtasks don't bleed into the new measurement window.
|
||||
await this.page.evaluate(() => {
|
||||
const win = window as unknown as Record<string, unknown>
|
||||
if (!win.__perfLongtaskState) {
|
||||
const state: { observer: PerformanceObserver; tbtMs: number } = {
|
||||
observer: new PerformanceObserver((list) => {
|
||||
const self = (window as unknown as Record<string, unknown>)
|
||||
.__perfLongtaskState as {
|
||||
observer: PerformanceObserver
|
||||
tbtMs: number
|
||||
}
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration > 50) self.tbtMs += entry.duration - 50
|
||||
}
|
||||
}),
|
||||
tbtMs: 0
|
||||
}
|
||||
state.observer.observe({ type: 'longtask', buffered: true })
|
||||
win.__perfLongtaskState = state
|
||||
}
|
||||
const state = win.__perfLongtaskState as {
|
||||
observer: PerformanceObserver
|
||||
tbtMs: number
|
||||
}
|
||||
state.tbtMs = 0
|
||||
state.observer.takeRecords()
|
||||
})
|
||||
this.snapshot = await this.getSnapshot()
|
||||
}
|
||||
|
||||
@@ -82,6 +176,11 @@ export class PerformanceHelper {
|
||||
return after[key] - before[key]
|
||||
}
|
||||
|
||||
const [totalBlockingTimeMs, frameDurationMs] = await Promise.all([
|
||||
this.collectTBT(),
|
||||
this.measureFrameDuration()
|
||||
])
|
||||
|
||||
return {
|
||||
name,
|
||||
durationMs: delta('Timestamp') * 1000,
|
||||
@@ -90,7 +189,13 @@ export class PerformanceHelper {
|
||||
layouts: delta('LayoutCount'),
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize')
|
||||
heapDeltaBytes: delta('JSHeapUsedSize'),
|
||||
domNodes: delta('Nodes'),
|
||||
jsHeapTotalBytes: delta('JSHeapTotalSize'),
|
||||
scriptDurationMs: delta('ScriptDuration') * 1000,
|
||||
eventListeners: delta('JSEventListeners'),
|
||||
totalBlockingTimeMs,
|
||||
frameDurationMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -1,4 +1,3 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Keybinding } from '../../src/platform/keybindings/types'
|
||||
@@ -72,6 +71,10 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
test.describe('Execution error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
@@ -88,117 +91,58 @@ test.describe('Execution error', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models warning', () => {
|
||||
test('Should be disabled by default in browser tests', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Missing models in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.page.evaluate((url: string) => {
|
||||
return fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found', async ({
|
||||
test('Should show error overlay with missing models when workflow has missing models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found in node properties', async ({
|
||||
test('Should show missing models from node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Load workflow that has a node with models metadata at the node level
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display a warning when no missing models are found', async ({
|
||||
test('Should not show missing models when widget values have changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const modelFoldersRes = {
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
{
|
||||
name: 'text_encoders',
|
||||
folders: ['ComfyUI/models/text_encoders']
|
||||
}
|
||||
])
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/experiment/models',
|
||||
(route) => route.fulfill(modelFoldersRes),
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
// Reload page to trigger indexing of model folders
|
||||
await comfyPage.setup()
|
||||
|
||||
const clipModelsRes = {
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
{
|
||||
name: 'fake_model.safetensors',
|
||||
pathIndex: 0
|
||||
}
|
||||
])
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/experiment/models/text_encoders',
|
||||
(route) => route.fulfill(clipModelsRes),
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// This tests the scenario where outdated model metadata exists in the workflow
|
||||
// but the actual selected models (widget values) have changed
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -206,14 +150,10 @@ test.describe('Missing models warning', () => {
|
||||
test.skip('Should download missing model when clicking download button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// The fake_model.safetensors is served by
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
@@ -223,50 +163,6 @@ test.describe('Missing models warning', () => {
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
})
|
||||
|
||||
test.describe('Do not show again checkbox', () => {
|
||||
let checkbox: Locator
|
||||
let closeButton: Locator
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
checkbox = comfyPage.page.getByLabel("Don't show this again")
|
||||
closeButton = comfyPage.page.getByLabel('Close')
|
||||
})
|
||||
|
||||
test('Should disable warning dialog when checkbox is checked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const changeSettingPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
await checkbox.click()
|
||||
await changeSettingPromise
|
||||
|
||||
await closeButton.click()
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
expect(settingValue).toBe(false)
|
||||
})
|
||||
|
||||
test('Should keep warning dialog enabled when checkbox is unchecked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await closeButton.click()
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
expect(settingValue).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
@@ -328,12 +224,14 @@ test.describe('Settings', () => {
|
||||
})
|
||||
await newBlankWorkflowRow.click()
|
||||
|
||||
// Click edit button
|
||||
const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil')
|
||||
await editKeybindingButton.click()
|
||||
// Click add keybinding button (New Blank Workflow has no default keybinding)
|
||||
const addKeybindingButton = newBlankWorkflowRow.locator(
|
||||
'.icon-\\[lucide--plus\\]'
|
||||
)
|
||||
await addKeybindingButton.click()
|
||||
|
||||
// Set new keybinding
|
||||
const input = comfyPage.page.getByPlaceholder('Press keys for new binding')
|
||||
const input = comfyPage.page.getByPlaceholder('Enter your keybind')
|
||||
await input.press('Alt+n')
|
||||
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
@@ -345,7 +243,7 @@ test.describe('Settings', () => {
|
||||
|
||||
// Save keybinding
|
||||
const saveButton = comfyPage.page
|
||||
.getByLabel('New Blank Workflow')
|
||||
.getByLabel('Modify keybinding')
|
||||
.getByText('Save')
|
||||
await saveButton.click()
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
44
browser_tests/tests/groupCopyPaste.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('Pasted group is offset from original position', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
|
||||
|
||||
const titlePos = await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups[0]
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
})
|
||||
await comfyPage.canvas.click({ position: titlePos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const positions = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
||||
x: g.pos[0],
|
||||
y: g.pos[1]
|
||||
}))
|
||||
)
|
||||
|
||||
expect(positions).toHaveLength(2)
|
||||
const dx = Math.abs(positions[0].x - positions[1].x)
|
||||
const dy = Math.abs(positions[0].y - positions[1].y)
|
||||
expect(dx).toBeCloseTo(50, 0)
|
||||
expect(dy).toBeCloseTo(15, 0)
|
||||
})
|
||||
})
|
||||
123
browser_tests/tests/groupSelectChildren.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Returns the client-space position of a group's title bar (for clicking).
|
||||
*/
|
||||
async function getGroupTitlePosition(
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pos = await comfyPage.page.evaluate((title) => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups.find(
|
||||
(g: { title: string }) => g.title === title
|
||||
)
|
||||
if (!group) return null
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
|
||||
* from the canvas in the browser.
|
||||
*/
|
||||
async function getSelectionCounts(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
let selectedNodeCount = 0
|
||||
let selectedGroupCount = 0
|
||||
for (const item of canvas.selectedItems) {
|
||||
if ('inputs' in item || 'outputs' in item) selectedNodeCount++
|
||||
else selectedGroupCount++
|
||||
}
|
||||
return {
|
||||
selectedNodeCount,
|
||||
selectedGroupCount,
|
||||
selectedItemCount: canvas.selectedItems.size
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('Setting enabled: clicking outer group selects nested group and inner node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const counts = await getSelectionCounts(comfyPage)
|
||||
// Outer Group + Inner Group + 1 node = 3 items
|
||||
expect(counts.selectedItemCount).toBe(3)
|
||||
expect(counts.selectedGroupCount).toBe(2)
|
||||
expect(counts.selectedNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Setting disabled: clicking outer group selects only the group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(1)
|
||||
expect(counts.selectedGroupCount).toBe(1)
|
||||
expect(counts.selectedNodeCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Deselecting outer group deselects all children', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the outer group (cascades to children)
|
||||
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(3)
|
||||
|
||||
// Deselect all via page.evaluate to avoid UI overlay interception
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.deselectAll()
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(0)
|
||||
})
|
||||
})
|
||||
58
browser_tests/tests/imagePastePriority.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Image paste priority over stale node metadata',
|
||||
{ tag: ['@node'] },
|
||||
() => {
|
||||
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBe(2)
|
||||
|
||||
// Copy the KSampler node (puts data-metadata in clipboard)
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
|
||||
// Select the LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
|
||||
// Simulate pasting when clipboard has stale node metadata (text/html
|
||||
// with data-metadata) but no image file items. This replicates the bug
|
||||
// scenario: user copied a node, then copied a web image (which replaces
|
||||
// clipboard files but may leave stale text/html with node metadata).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
|
||||
const base64 = btoa(JSON.stringify(nodeData))
|
||||
const html =
|
||||
'<meta charset="utf-8"><div><span data-metadata="' +
|
||||
base64 +
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node count should remain the same — stale node metadata should NOT
|
||||
// be deserialized when a media node is selected.
|
||||
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(finalCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 90 KiB |