Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
e154bba37e feat: add proxyWidgetSelector support to SubgraphNode 2026-04-03 18:51:54 -04:00
1384 changed files with 23199 additions and 97471 deletions

View File

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

View File

@@ -11,11 +11,10 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
4. **Plan**Order by dependency (leaf fixes first), group into waves per branch
5. **Execute**Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
6. **Verify** — After each wave, verify branch integrity before proceeding
7. **Log & Report** — Generate session report (`reference/logging.md`)
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
@@ -39,28 +38,15 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
**Critical: Match PRs to the correct target branches.**
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ------------------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
| ------------- | ------------------------------ | ----------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
### What Goes Where
**⚠️ 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:
**Both core + cloud:**
- **App mode** PRs — app mode is NOT cloud-only
- **Firebase auth** PRs — Firebase auth is on core for API nodes
- **Payment redirect** PRs — payment infrastructure shared
- **Bug fixes** touching shared components
**Cloud-only (skip for core):**
- Team workspaces
- Cloud queue virtualization
- Hide API key login
- Cloud-specific UI behind cloud feature flags
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
- 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)
@@ -81,32 +67,6 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
### Accept-Theirs Can Produce Broken Hybrids
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
### Cherry-Picks Can Reference Missing Dependencies
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
### Use `--no-verify` for Worktree Pushes
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
### Automation Success Varies Wildly by Branch
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
@@ -117,8 +77,6 @@ In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/2
| **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) |
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
@@ -145,7 +103,7 @@ Skip these without discussion:
- **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** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
## Wave Verification
@@ -164,18 +122,6 @@ git worktree remove /tmp/verify-TARGET --force
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
### Fix PRs Are Normal
Expect to create 1 fix PR per branch after verification. Common issues:
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
### Never Admin-Merge Without CI
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
@@ -189,43 +135,6 @@ Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuo
- Reserve session-style bulk backporting for catching up after gaps
- When a release branch is created, immediately start the continuous process
## Interactive Approval Flow
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
### Batch Presentation
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
```
# | PR | Title | Target | Rec | Context
----+--------+------------------------------------------+---------------+------+--------
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
```
Each row includes:
- PR number and title
- Target branches
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
### Human Response Format
- `Y` — approve for backport
- `N` — skip
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
- Any freeform question or comment triggers discussion before moving on
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
### Rules
- ALL candidates are reviewed, not just MUST items
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
## Quick Reference
### Label-Driven Automation (default path)
@@ -241,96 +150,13 @@ gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
```bash
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
cd /tmp/backport-$BRANCH
# For each PR:
git fetch origin $BRANCH
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
git cherry-pick -m 1 $MERGE_SHA
# Resolve conflicts (see Conflict Triage)
git push origin backport-$PR-to-$BRANCH --no-verify
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
--title "[backport $BRANCH] $TITLE (#$PR)" \
--body "Backport of #$PR. [conflict notes]"
gh pr merge $NEW_PR --squash --admin
sleep 25
# Resolve conflicts, push, create PR, merge
```
### Efficient Batch: Test-Then-Resolve Pattern
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
```bash
cd /tmp/backport-$BRANCH
for pr in "${ORDER[@]}"; do
git checkout -b test-$pr origin/$BRANCH
if git cherry-pick -m 1 $SHA 2>/dev/null; then
echo "CLEAN: $pr"
else
echo "CONFLICT: $pr"
git cherry-pick --abort
fi
git checkout --detach HEAD
git branch -D test-$pr
done
```
Then process clean PRs in a batch loop, conflicts individually.
### PR Title Convention
```
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
```
## Final Deliverables (Slack-Compatible)
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
### 1. Author Accountability Report
File: `backport-author-accountability.md`
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
```
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
- author-login
- #1234 fix: short title
- #5678 fix: another title
- other-author
- #9012 fix: some other fix
```
Authors sorted alphabetically, 4-space indent for nested items.
### 2. Slack Status Update
File: `slack-status-update.md`
A shareable summary of the session. Structure:
```
Backport session complete -- YYYY-MM-DD
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
Branches updated:
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
- ...
N total PRs created and merged (N backports + N fix PRs).
Notable fixes included:
- [category]: [list of fixes]
- ...
Conflict patterns encountered:
- [pattern and how it was resolved]
- ...
N authors had PRs backported. See author accountability list for details.
```
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.

View File

@@ -24,8 +24,8 @@ For SHOULD items with conflicts: if conflict resolution requires more than trivi
**Before categorizing, filter by branch scope:**
| Target branch | Skip if PR is... |
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
| ------------- | ------------------------------------------------------------------- |
| `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.
@@ -61,6 +61,8 @@ done
## Human Review Checkpoint
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
Present decisions.md before execution. Include:
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
1. All MUST/SHOULD/SKIP categorizations with rationale
2. Questions for human (feature existence, scope, deps)
3. Estimated effort per branch

View File

@@ -73,22 +73,14 @@ for PR in ${CONFLICT_PRS[@]}; do
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, component rewrites
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
# See SKILL.md Conflict Triage table for resolution per type.
# For component rewrites (4+ markers in a .vue file, library migration):
# DO NOT use accept-theirs regex — it produces broken hybrids.
# Instead, use the complete file from the merge commit:
# git show $MERGE_SHA:path/to/file > path/to/file
# For simple content conflicts, accept theirs:
# python3 -c "import re; ..."
# Resolve all conflicts, then:
git add .
GIT_EDITOR=true git cherry-pick --continue
git push origin backport-$PR-to-TARGET --no-verify
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+$')
@@ -122,30 +114,7 @@ source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm tes
git worktree remove /tmp/verify-TARGET --force
```
If verification fails, **do not skip** — create a fix PR:
```bash
# Stay in the verify worktree
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
# Common fixes:
# 1. Component rewrite hybrids: overwrite with merge commit version
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
# 2. Missing dependency files
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
# 3. Missing type properties: edit the interface
# 4. Unused imports: delete the import lines
git add -A
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
git push origin fix-backport-TARGET --no-verify
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
gh pr merge $PR --squash --admin
```
Do not proceed to the next branch until typecheck passes.
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
## Conflict Resolution Patterns
@@ -173,35 +142,7 @@ git rm $FILE
git checkout --theirs $FILE && git add $FILE
```
### 4. Component Rewrites (DO NOT accept-theirs)
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
a broken hybrid with mismatched template/script sections.
```bash
# Use the complete correct file from the merge commit instead:
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
```
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
### 5. Missing Dependencies After Cherry-Pick
Cherry-picks can succeed but leave the branch broken because the PR's code on main
references composables/components introduced by an earlier PR.
```bash
# Add the missing file from the merge commit:
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
```
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
### 6. Locale Files
### 4. Locale Files
Usually adding new i18n keys — accept theirs, validate JSON:
@@ -235,14 +176,8 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
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 && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
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.
12. **Never admin-merge without CI**`--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
## CI Failure Triage

View File

@@ -2,25 +2,26 @@
## During Execution
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | ------ | ----------- | ------- |
| #XXXX | Title | merged | #YYYY | Details |
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
Track verification results per wave within execution-log.md:
Track verification results per wave:
```markdown
Wave N Verification -- TARGET_BRANCH
## Wave N Verification TARGET_BRANCH
- PRs merged: #A, #B, #C
- Typecheck: pass / fail
- Fix PR: #YYYY (if needed)
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
## Session Report Template
@@ -62,42 +63,40 @@ Wave N Verification -- TARGET_BRANCH
- Feature branches that need tracking for future sessions?
```
## Final Deliverables
## Final Deliverable: Visual Summary
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
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.
### 1. execution-log.md (internal)
```mermaid
graph TD
subgraph branch1["☁️ cloud/X.XX — N PRs"]
C1["#XXXX title"]
C2["#XXXX title"]
end
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
M1["#XXXX title"]
end
### 2. backport-author-accountability.md (Slack-compatible)
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
S1["#XXXX-#XXXX N auto-merged"]
S2["#XXXX-#XXXX N manual picks"]
end
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
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
```
### 3. slack-status-update.md (Slack-compatible)
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
## Slack Formatting Rules
Both shareable files (author accountability + status update) must follow these rules:
- No emojis (no checkmarks, no arrows, no icons)
- No markdown tables (use plain lists with dashes)
- No headers (no # or ##)
- No bold (\*_) or italic (_)
- No inline code backticks
- Use -- instead of em dash
- Use plain dashes (-) for lists with 4-space indent for nesting
- Line breaks between sections for readability
These files should paste directly into a Slack message and look clean.
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
## Files to Track
All in `~/temp/backport-session/`:
- `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
- `execution-plan.md` -- approved PRs with merge SHAs (input)
- `execution-log.md` -- real-time status with per-branch tables (internal)
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
- `slack-status-update.md` -- session summary (Slack-compatible)
All in `~/temp/backport-session/`.

View File

@@ -1,246 +0,0 @@
---
name: hardening-flaky-e2e-tests
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
---
# Hardening Flaky E2E Tests
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
## Workflow
### 1. Gather CI Evidence
```bash
gh run list --workflow=ci-test.yaml --limit=5
gh run download <run-id> -n playwright-report
```
- Open `report.json` and search for `"status": "flaky"` entries.
- Collect file paths, test titles, and error messages.
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
- Use `error-context.md`, traces, and page snapshots before editing code.
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
### 2. Classify the Flake
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
| # | Pattern | Signature in Code | Fix |
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
### 3. Apply the Transform
#### Rule: Choose the Smallest Correct Assertion
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
- **Never** use `waitForTimeout()` to hide a race.
```typescript
// ✅ Single value — use expect.poll
await expect
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
.toBe(3)
// ✅ Locator count — use toHaveCount
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
// ✅ Multiple conditions — use toPass
await expect(async () => {
expect(await node1.getValue()).toBe('foo')
expect(await node2.getValue()).toBe('bar')
}).toPass({ timeout: 5000 })
```
#### Rule: Wait for the Real Readiness Boundary
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
Common readiness boundaries:
| After this action... | Wait for... |
| -------------------------------------- | ------------------------------------------------------------ |
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
| Menu item click | `await contextMenu.waitForHidden()` |
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
| Settings write | Poll the setting value with `expect.poll()` |
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
| Graph mutation (add/remove node, link) | Poll link/node count |
| Clipboard write | Poll pasted value |
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
#### Rule: Expose Locators for Retrying Assertions
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
```typescript
// Helper exposes locator
get domWidgets(): Locator {
return this.page.locator('.dom-widget')
}
// Caller uses retrying assertion
await expect(comfyPage.domWidgets).toHaveCount(2)
```
Replace count methods with locator getters so callers can use retrying assertions directly.
#### Rule: Fix Check-then-Act Races in Helpers
```typescript
// ❌ Race: count can change between check and waitFor
const count = await locator.count()
if (count > 0) {
await locator.waitFor({ state: 'hidden' })
}
// ✅ Direct: waitFor handles both cases
await locator.waitFor({ state: 'hidden' })
```
#### Rule: Remove force:true from Clicks
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
```typescript
// ❌ Hides the race
await closeButton.click({ force: true })
// ✅ Surfaces the real issue — fix with proper wait
await closeButton.click()
await dialog.waitForHidden()
```
#### Rule: Handle Non-deterministic Element Order
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
```typescript
// ❌ Assumes order
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
// ✅ Find by ID or proximity
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
let target = nodes[0]
for (const n of nodes) {
const pos = await n.getPosition()
if (Math.abs(pos.y - expectedY) < minDist) target = n
}
```
Or guard the assumption:
```typescript
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes).toHaveLength(1)
const node = nodes[0]
```
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
```typescript
// ✅ Retry click+assert together
await expect(async () => {
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
await expect(dialog).toBeHidden({ timeout: 500 })
}).toPass({ timeout: 5000 })
```
### 4. Keep Changes Narrow
- Shared helpers should drive setup to a stable boundary.
- Do not encode one-spec timing assumptions into generic helpers.
- If a race only matters to one spec, prefer a local wait in that spec.
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
### 5. Verify Narrowly
```bash
# Targeted rerun with repetition
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
# Single test by line number (avoids grep quoting issues on Windows)
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
```
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
- Verify with the smallest command that exercises the flaky path.
### 6. Watch CI E2E Runs
After pushing, use `gh` to monitor the E2E workflow:
```bash
# Find the run for the current branch
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
# Watch it live (blocks until complete, streams logs)
gh run watch <run-id>
# One-liner: find and watch the latest E2E run for the current branch
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
```
On Windows (PowerShell):
```powershell
# One-liner equivalent
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
```
After the run completes:
```bash
# Download the Playwright report artifact
gh run download <run-id> -n playwright-report
# View the run summary in browser
gh run view <run-id> --web
```
Also watch the unit test workflow in parallel if you changed helpers:
```bash
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
```
### 7. Pre-merge Checklist
Before merging a flaky-test fix, confirm:
- [ ] The latest CI artifact was inspected directly
- [ ] The root cause is stated as a race or readiness mismatch
- [ ] The fix waits on the real readiness boundary
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
- [ ] The fix stays local unless a shared helper truly owns the race
- [ ] Local verification uses a targeted rerun
- [ ] No behavioral changes to the test — only timing/retry strategy updated
## Local Noise — Do Not Fix
These are local distractions, not CI root causes:
- Missing local input fixture files required by the test path
- Missing local models directory
- Teardown `EPERM` while restoring the local browser-test user data directory
- Local screenshot baseline differences on Windows
Rules:
- First confirm whether it blocks the exact flaky path under investigation.
- Do not commit temporary local assets used only for verification.
- Do not commit local screenshot baselines.

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

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

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

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

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -1,65 +0,0 @@
name: Find Workflow Run
description: Finds a workflow run for a given commit SHA and outputs its status and run ID.
inputs:
workflow-id:
description: The workflow filename (e.g., 'ci-size-data.yaml')
required: true
head-sha:
description: The commit SHA to find runs for
required: true
not-found-status:
description: Status to output when no run exists
required: false
default: pending
token:
description: GitHub token for API access
required: true
outputs:
status:
description: One of 'ready', 'pending', 'failed', or the not-found-status value
value: ${{ steps.find.outputs.status }}
run-id:
description: The workflow run ID (only set when status is 'ready')
value: ${{ steps.find.outputs.run-id }}
runs:
using: composite
steps:
- name: Find workflow run
id: find
uses: actions/github-script@v8
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
script: |
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', process.env.NOT_FOUND_STATUS);
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));

View File

@@ -1,31 +0,0 @@
name: 'Lint and format verify'
description: >
Runs the lint/format/knip verification suite plus a conditional
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
same checks. The caller is responsible for checkout and frontend setup
before invoking this action.
runs:
using: composite
steps:
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Verify lint and format
shell: bash
run: |
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
shell: bash
run: pnpm typecheck:browser

View File

@@ -1,88 +0,0 @@
name: Resolve PR from workflow_run
description: >
Resolves the PR number from a workflow_run event using pull_requests[0]
with a listPullRequestsAssociatedWithCommit fallback.
Skips closed/merged PRs and stale runs (head SHA mismatch).
inputs:
token:
description: GitHub token for API calls
required: false
default: ${{ github.token }}
outputs:
skip:
description: "'true' when no open PR was found or the run is stale"
value: ${{ steps.resolve.outputs.skip }}
number:
description: The PR number (empty when skip is true)
value: ${{ steps.resolve.outputs.number }}
base:
description: The PR base branch (empty when skip is true)
value: ${{ steps.resolve.outputs.base }}
head-sha:
description: The PR head SHA (empty when skip is true)
value: ${{ steps.resolve.outputs.head-sha }}
runs:
using: composite
steps:
- name: Resolve PR
id: resolve
uses: actions/github-script@v8
with:
github-token: ${{ inputs.token }}
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
// Fork PRs: pull_requests is empty and commit SHA may not be in
// the base repo graph. Fall back to pulls.list with head filter.
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
per_page: 1,
});
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.state !== 'open') {
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
core.setOutput('skip', 'true');
return;
}
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
);
core.setOutput('skip', 'true');
return;
}
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));

View File

@@ -0,0 +1,107 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types'
on:
# Manual trigger
workflow_dispatch:
# Triggered from comfy-api repo
repository_dispatch:
types: [comfy-api-updated]
jobs:
update-registry-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v6
with:
repository: Comfy-Org/comfy-api
path: comfy-api
token: ${{ secrets.COMFY_API_PAT }}
clean: true
- name: Get API commit information
id: api-info
run: |
cd comfy-api
API_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
mkdir -p ./packages/registry-types/src
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in Comfy Registry API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification.
- API commit: ${{ steps.api-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-registry-types-${{ steps.api-info.outputs.commit }}
base: main
labels: CNR
delete-branch: true
add-paths: |
packages/registry-types/src/comfyRegistryTypes.ts

View File

@@ -1,29 +0,0 @@
# Description: Lint and format verification for GitHub merge queue runs.
# Paired with ci-lint-format.yaml — workflow name and job name must match
# so branch protection resolves a single required check in both the
# pull_request and merge_group contexts. This file runs verify-only steps
# with a read-only token; auto-fix and PR comments live in the PR workflow.
name: 'CI: Lint Format'
on:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout merge group ref
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Verify lint and format
uses: ./.github/actions/lint-format-verify

View File

@@ -1,7 +1,4 @@
# Description: Linting and code formatting validation for pull requests.
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
# match so branch protection resolves a single required check in both the
# pull_request and merge_group contexts.
# Description: Linting and code formatting validation for pull requests
name: 'CI: Lint Format'
on:
@@ -29,6 +26,14 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Run ESLint with auto-fix
run: pnpm lint:fix
@@ -72,8 +77,16 @@ jobs:
echo "See CONTRIBUTING.md for more details."
exit 1
- name: Verify lint and format
uses: ./.github/actions/lint-format-verify
- name: Final validation
run: |
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
run: pnpm typecheck:browser
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository

View File

@@ -58,6 +58,21 @@ jobs:
retention-days: 30
if-no-files-found: warn
- name: Save PR metadata
if: github.event_name == 'pull_request'
run: |
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v6
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

View File

@@ -32,6 +32,13 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6
with:

View File

@@ -1,147 +0,0 @@
name: 'CI: E2E Coverage'
on:
workflow_run:
workflows: ['CI: Tests E2E']
types:
- completed
concurrency:
group: e2e-coverage-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
merge:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download all shard coverage data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: e2e-coverage-shard-.*
name_is_regexp: true
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Install lcov
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
path: coverage/playwright/
retention-days: 30
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: reports
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -72,6 +85,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -8,7 +8,6 @@ on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
workflow_dispatch:
concurrency:
@@ -87,7 +86,6 @@ jobs:
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
- name: Upload blob report
uses: actions/upload-artifact@v6
@@ -97,15 +95,6 @@ jobs:
path: blob-report/
retention-days: 1
- name: Upload shard coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-shard-${{ matrix.shardIndex }}
path: coverage/playwright/
retention-days: 1
if-no-files-found: warn
playwright-tests:
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -69,6 +82,6 @@ jobs:
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -8,7 +8,6 @@ on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -27,20 +26,9 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
with:
name: unit-coverage
path: coverage/lcov.info
retention-days: 30
if-no-files-found: warn
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View File

@@ -1,158 +0,0 @@
---
name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
env:
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview
- name: Build project artifacts
run: vercel build
- name: Fetch head commit metadata
id: head-commit
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
})
const author = data.author?.login || data.commit.author?.name || ''
const message = (data.commit.message || '').split('\n', 1)[0]
core.setOutput('author', author)
core.setOutput('message', message)
- name: Deploy project artifacts to Vercel
id: deploy
env:
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
GIT_PR_ID: ${{ github.event.pull_request.number }}
GIT_REPO: ${{ github.repository }}
run: |
URL=$(vercel deploy --prebuilt \
--meta githubCommitRef="$GIT_COMMIT_REF" \
--meta githubCommitSha="$GIT_COMMIT_SHA" \
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
--meta githubPrId="$GIT_PR_ID" \
--meta githubRepo="$GIT_REPO")
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Alias deployment to stable PR hostname
id: alias-set
continue-on-error: true
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
run: |
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
- name: Publish preview outputs
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
run: |
if [[ "$ALIAS_OK" == "true" ]]; then
STABLE_URL="https://$ALIAS_HOST"
else
STABLE_URL="$DEPLOY_URL"
fi
mkdir -p temp/vercel-preview
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
{
echo "**Preview:** $STABLE_URL"
if [[ "$ALIAS_OK" == "true" ]]; then
echo "**This commit:** $DEPLOY_URL"
else
echo "_Stable alias update failed — URL reflects this commit only._"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload preview metadata
uses: actions/upload-artifact@v6
with:
name: vercel-preview
path: temp/vercel-preview
deploy-production:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Build project artifacts
run: vercel build --prod
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,33 +0,0 @@
# Description: Build and validate the marketing website (apps/website)
name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build website
run: pnpm --filter @comfyorg/website build

View File

@@ -1,201 +0,0 @@
name: 'CI: Website E2E'
on:
push:
branches: [main, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
website-e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Post starting comment
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- WEBSITE_E2E_STATUS -->'
const body = [
marker,
'## 🌐 Website E2E',
'',
'> [!NOTE]',
`> Tests are running… [View workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
].join('\n')
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number
})
const existing = comments.find(c => c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
}
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
id: tests
run: pnpm --filter @comfyorg/website test:e2e
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-playwright-report
path: apps/website/playwright-report/
retention-days: 30
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g;s/--*/-/g;s/^-\|-$//g')
DEPLOY_OK=false
for i in 1 2 3; do
echo "Deployment attempt $i of 3..."
OUTPUT=$(npx wrangler@^4.0.0 pages deploy apps/website/playwright-report \
--project-name=comfyui-website-e2e \
--branch="$BRANCH" 2>&1) && { DEPLOY_OK=true; break; } || echo "$OUTPUT"
[ $i -lt 3 ] && sleep 10
done
echo "$OUTPUT"
if [ "$DEPLOY_OK" != "true" ]; then
echo "::error::All 3 deployment attempts failed"
exit 1
fi
URL=$(echo "$OUTPUT" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
echo "url=${URL}" >> $GITHUB_OUTPUT
- name: Categorize failures
id: failures
if: always() && !cancelled() && steps.tests.outcome != 'success'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs')
const report = JSON.parse(fs.readFileSync('apps/website/results.json', 'utf8'))
function isFailed(t) { return t.status === 'unexpected' || t.status === 'flaky' }
function isVisual(spec) {
return spec.file?.includes('visual') ||
spec.tests?.some(t => t.results?.some(r => r.error?.message?.includes('toHaveScreenshot')))
}
function specsOf(suite) {
return [
...(suite.specs || []),
...(suite.suites || []).flatMap(specsOf)
]
}
// True: Visual
// False: Other
const failed = specsOf(report)
.flatMap(spec => (spec.tests || [])
.filter(isFailed)
.map(() => isVisual(spec)))
const screenshotFailures = failed.filter(Boolean).length
core.setOutput('screenshot', screenshotFailures)
core.setOutput('other', failed.length - screenshotFailures)
- name: Post result comment and summary
if: always() && !cancelled()
uses: actions/github-script@v8
with:
script: |
const passed = '${{ steps.tests.outcome }}' === 'success'
const reportUrl = '${{ steps.deploy.outputs.url }}'
const screenshotFailures = parseInt('${{ steps.failures.outputs.screenshot }}') || 0
const otherFailures = parseInt('${{ steps.failures.outputs.other }}') || 0
const lines = ['## 🌐 Website E2E', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
// Results table
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
if (screenshotFailures > 0) {
const s = screenshotFailures === 1 ? '' : 's'
lines.push('', `- [ ] Update website screenshots (${screenshotFailures} screenshot diff${s})`)
}
if (otherFailures > 0) {
lines.push(
'',
'> [!WARNING]',
`> ${otherFailures} non-screenshot failure${otherFailures === 1 ? '' : 's'} — these require manual review.`
)
}
const summary = lines.join('\n')
await core.summary.addRaw(summary).write()
if (context.eventName === 'pull_request' && !context.payload.pull_request.head.repo.fork) {
const marker = '<!-- WEBSITE_E2E_STATUS -->'
const body = `${marker}\n${summary}`
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number
})
const existing = comments.find(c => c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
}
}

View File

@@ -1,149 +0,0 @@
name: 'Coverage: Slack Notification'
on:
workflow_run:
workflows: ['CI: Tests Unit']
branches: [main]
types:
- completed
permissions:
contents: read
actions: read
pull-requests: read
jobs:
notify:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download current unit coverage
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: unit-coverage
path: coverage
- name: Download previous unit coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: unit-coverage-baseline
path: temp/coverage-baseline
if_no_artifact_found: warn
- name: Download latest E2E coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: ci-tests-e2e-coverage.yaml
name: e2e-coverage
path: temp/e2e-coverage
if_no_artifact_found: warn
- name: Download previous E2E coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: e2e-coverage-baseline
path: temp/e2e-coverage-baseline
if_no_artifact_found: warn
- name: Resolve merged PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const sha = context.payload.workflow_run.head_sha;
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: sha,
});
const message = commit.commit.message ?? '';
const firstLine = message.split('\n')[0];
const match = firstLine.match(/\(#(\d+)\)\s*$/);
if (!match) {
core.setOutput('skip', 'true');
core.info('No PR number found in commit message — skipping.');
return;
}
const prNumber = match[1];
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('skip', 'false');
core.setOutput('number', prNumber);
core.setOutput('url', pr.html_url);
core.setOutput('author', pr.user.login);
- name: Generate Slack notification
if: steps.pr-meta.outputs.skip != 'true'
id: slack-payload
env:
PR_URL: ${{ steps.pr-meta.outputs.url }}
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
run: |
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
--pr-url="$PR_URL" \
--pr-number="$PR_NUMBER" \
--author="$PR_AUTHOR")
if [ -n "$PAYLOAD" ]; then
echo "has_payload=true" >> "$GITHUB_OUTPUT"
DELIM="SLACK_PAYLOAD_$(date +%s)"
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
echo "$DELIM" >> "$GITHUB_OUTPUT"
else
echo "has_payload=false" >> "$GITHUB_OUTPUT"
fi
- name: Post to Slack
if: steps.slack-payload.outputs.has_payload == 'true'
continue-on-error: true
env:
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: |
# Channel: #p-deprecated-frontend-automated-testing
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
curl -sf -X POST \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" \
-o /dev/null \
https://slack.com/api/chat.postMessage
- name: Save unit coverage baseline
if: always() && hashFiles('coverage/lcov.info') != ''
uses: actions/upload-artifact@v6
with:
name: unit-coverage-baseline
path: coverage/lcov.info
retention-days: 90
if-no-files-found: warn
- name: Save E2E coverage baseline
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-baseline
path: temp/e2e-coverage/coverage.lcov
retention-days: 90
if-no-files-found: warn

View File

@@ -2,7 +2,7 @@ name: 'PR: Unified Report'
on:
workflow_run:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
workflows: ['CI: Size Data', 'CI: Performance Report']
types:
- completed
@@ -30,25 +30,110 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
- name: Find size workflow run
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
- name: Find size workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-size
uses: ./.github/actions/find-workflow-run
uses: actions/github-script@v8
with:
workflow-id: ci-size-data.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-size-data.yaml',
head_sha: headSha,
per_page: 1,
});
- name: Find perf workflow run
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Find perf workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-perf
uses: ./.github/actions/find-workflow-run
uses: actions/github-script@v8
with:
workflow-id: ci-perf-report.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-perf-report.yaml',
head_sha: headSha,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Download size data (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
@@ -69,25 +154,6 @@ jobs:
path: temp/size-prev
if_no_artifact_found: warn
- name: Find coverage workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-coverage
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-e2e-coverage.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
not-found-status: skip
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download coverage data
if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: e2e-coverage
run_id: ${{ steps.find-coverage.outputs.run-id }}
path: temp/coverage
if_no_artifact_found: warn
- name: Download perf metrics (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
@@ -123,10 +189,9 @@ jobs:
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >
pnpm exec tsx scripts/unified-report.ts
node scripts/unified-report.js
--size-status=${{ steps.find-size.outputs.status }}
--perf-status=${{ steps.find-perf.outputs.status }}
--coverage-status=${{ steps.find-coverage.outputs.status }}
> pr-report.md
- name: Remove legacy separate comments

View File

@@ -1,125 +0,0 @@
name: 'PR: Update Website Screenshots'
on:
pull_request:
types: [labeled]
issue_comment:
types: [created, edited]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
steps:
- name: Verify sender permissions
if: >
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->')
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username: context.actor
})
if (!['admin', 'write'].includes(data.permission)) {
core.setFailed(`User ${context.actor} does not have write access`)
}
- name: Get PR info
id: pr-info
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ github.event.number || github.event.issue.number }}
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: prNumber
})
core.setOutput('pr-number', prNumber)
core.setOutput('branch', pr.head.ref)
- uses: actions/checkout@v6
with:
ref: ${{ steps.pr-info.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
id: update-screenshots
run: pnpm --filter @comfyorg/website test:visual:update
continue-on-error: true
- name: Commit updated screenshots
id: commit
if: steps.update-screenshots.outcome == 'success'
run: |
git config --global --add safe.directory "$(pwd)"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/)" ]; then
echo "No screenshot changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "has-changes=true" >> $GITHUB_OUTPUT
git add apps/website/e2e/
git commit -m "[automated] Update website screenshot expectations"
git push origin ${{ steps.pr-info.outputs.branch }}
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-screenshot-update-report
path: apps/website/playwright-report/
retention-days: 14
- name: Remove label
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v8
with:
script: |
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: ${{ steps.pr-info.outputs.pr-number }},
name: 'Update Website Screenshots'
})
} catch (e) {
// Label may already be removed
}

View File

@@ -1,64 +0,0 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
permissions:
contents: read
pull-requests: write
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
env:
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
SHORT_SHA="${HEAD_SHA:0:7}"
cat > preview-report.md <<EOF
**Website Preview:** $STABLE_URL
<sub>This commit: $UNIQUE_URL</sub>
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
EOF
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./preview-report.md
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,23 +1,14 @@
# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR.
# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases.
name: 'Release: ComfyUI'
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases
name: 'Release: Bi-weekly ComfyUI'
on:
# Bi-weekly schedule: Monday at 20:00 UTC
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
schedule:
- cron: '0 20 * * 1'
# Manual trigger for both on-demand minor and patch/hotfix releases
# Allow manual triggering (bypasses bi-weekly check)
workflow_dispatch:
inputs:
release_type:
description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version'
required: true
default: 'minor'
type: choice
options:
- minor
- patch
comfyui_fork:
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
required: false
@@ -50,11 +41,10 @@ jobs:
- name: Summary
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -86,8 +76,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
package_json_file: frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -101,8 +89,6 @@ jobs:
- name: Resolve release information
id: resolve
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
run: |
set -euo pipefail

View File

@@ -142,20 +142,6 @@ jobs:
fi
echo "✅ Branch '$BRANCH' exists"
- name: Ensure packageManager field exists
run: |
if ! grep -q '"packageManager"' package.json; then
# Old branches (e.g. core/1.42) predate the packageManager field.
# Inject it so pnpm/action-setup can resolve the version.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
pkg.packageManager = 'pnpm@10.33.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Injected packageManager into package.json for legacy branch"
fi
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0

View File

@@ -64,7 +64,6 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -84,7 +83,6 @@
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
@@ -106,7 +104,8 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error"
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
"overrides": [
{
@@ -117,60 +116,13 @@
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error",
"playwright/consistent-spacing-between-blocks": "error",
"playwright/expect-expect": [
"error",
{
"assertFunctionNames": [
"recordMeasurement",
"logMeasurement",
"builderSaveAs"
],
"assertFunctionPatterns": [
"^expect",
"^assert",
"^verify",
"^searchAndExpect",
"waitForOpen",
"waitForClosed",
"waitForRequest"
]
}
],
"playwright/max-nested-describe": "error",
"playwright/no-duplicate-hooks": "error",
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",
"playwright/no-unsafe-references": "error",
"playwright/no-unused-locators": "error",
"playwright/no-useless-await": "error",
"playwright/no-useless-not": "error",
"playwright/no-wait-for-navigation": "error",
"playwright/no-wait-for-selector": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/prefer-hooks-on-top": "error",
"playwright/prefer-locator": "error",
"playwright/prefer-to-have-count": "error",
"playwright/prefer-to-have-length": "error",
"playwright/prefer-web-first-assertions": "error",
"playwright/prefer-native-locators": "error",
"playwright/require-to-pass-timeout": "error",
"playwright/valid-expect": "error",
"playwright/valid-expect-in-promise": "error",
"playwright/valid-title": "error"
"unicorn/no-empty-file": "error"
}
}
]

View File

@@ -67,7 +67,6 @@
"ignoreFiles": [
"node_modules/**",
"dist/**",
"**/dist/**",
"playwright-report/**",
"public/**",
"src/lib/litegraph/**"

View File

@@ -1,12 +1,16 @@
{
"recommendations": [
"antfu.vite",
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"oxc.oxc-vscode",

View File

@@ -44,7 +44,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
@@ -180,12 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Design Standards
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
## Testing Guidelines
See @docs/testing/\*.md for detailed patterns.
@@ -233,7 +226,6 @@ See @docs/testing/\*.md for detailed patterns.
- shadcn/vue: <https://www.shadcn-vue.com/>
- Reka UI: <https://reka-ui.com/>
- PrimeVue: <https://primevue.org>
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
@@ -312,16 +304,13 @@ When referencing Comfy-Org repos:
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`
- NEVER use `:class="[]"` to merge class names
- Always use `import { cn } from '@comfyorg/tailwind-utils'`
- Always use `import { cn } from '@/utils/tailwindUtil'`
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

@@ -62,37 +62,6 @@ python main.py --port 8188 --cpu
- Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
#### Testing with Cloud & Staging Environments
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
**Option 1: Frontend — `pnpm dev:cloud`**
The simplest approach. This proxies all API requests to the test cloud environment:
```bash
pnpm dev:cloud
```
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
```bash
# .env
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
```
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
**Option 2: Backend — `--comfy-api-base`**
Alternatively, launch the ComfyUI backend pointed at the staging API:
```bash
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
```
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.

View File

@@ -5,14 +5,12 @@
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",
"@vueuse/core": "catalog:",

View File

@@ -32,7 +32,7 @@ import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()

View File

@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
})
const ProgressBarStub = {
props: ['mode', 'value', 'showValue'],
template:
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
}
function renderDisplay(
props: {
progressPercentage?: number
title?: string
statusText?: string
hideProgress?: boolean
fullScreen?: boolean
} = {}
) {
return render(StartupDisplay, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: { ProgressBar: ProgressBarStub }
}
})
}
describe('StartupDisplay', () => {
describe('progressMode', () => {
it('renders indeterminate mode when progressPercentage is undefined', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'indeterminate'
)
})
it('renders determinate mode when progressPercentage is provided', () => {
renderDisplay({ progressPercentage: 50 })
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'determinate'
)
})
it('passes progressPercentage as value to the progress bar', () => {
renderDisplay({ progressPercentage: 75 })
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
})
})
describe('hideProgress', () => {
it('hides the progress bar when hideProgress is true', () => {
renderDisplay({ hideProgress: true })
expect(screen.queryByTestId('progress-bar')).toBeNull()
})
it('shows the progress bar by default', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar')).toBeDefined()
})
})
describe('title', () => {
it('renders the title text when provided', () => {
renderDisplay({ title: 'Loading...' })
expect(screen.getByText('Loading...')).toBeDefined()
})
it('does not render h1 when title is not provided', () => {
renderDisplay()
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
})
})
describe('statusText', () => {
it('renders statusText with data-testid attribute', () => {
renderDisplay({ statusText: 'Starting server' })
expect(screen.getByTestId('startup-status-text').textContent).toContain(
'Starting server'
)
})
it('does not render statusText element when not provided', () => {
renderDisplay()
expect(screen.queryByTestId('startup-status-text')).toBeNull()
})
})
})

View File

@@ -1,208 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
checkUrlReachable: vi.fn()
}))
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
import UrlInput from '@/components/common/UrlInput.vue'
import { ValidationState } from '@/utils/validationUtil'
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue', 'blur'],
template: `<input
data-testid="url-input"
:value="modelValue"
:data-invalid="invalid"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur')"
/>`
}
const InputIconStub = {
template: '<span data-testid="input-icon" />'
}
const IconFieldStub = {
template: '<div><slot /></div>'
}
function renderUrlInput(
modelValue = '',
validateUrlFn?: (url: string) => Promise<boolean>
) {
return render(UrlInput, {
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
}
describe('UrlInput', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('initial validation on mount', () => {
it('stays IDLE when modelValue is empty on mount', async () => {
renderUrlInput('')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(true)
renderUrlInput('https://example.com')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets INVALID state when URL is not reachable on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://unreachable.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
})
})
describe('input handling', () => {
it('resets validation state to IDLE on user input', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://bad.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
const user = userEvent.setup()
await user.type(screen.getByTestId('url-input'), 'x')
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
it('strips whitespace from typed input', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
const input = screen.getByTestId('url-input')
await user.type(input, 'htt ps')
expect((input as HTMLInputElement).value).not.toContain(' ')
})
})
describe('blur handling', () => {
it('emits update:modelValue on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
expect(onUpdate).toHaveBeenCalled()
})
it('normalizes URL on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
const emittedUrl = onUpdate.mock.calls[0]?.[0]
expect(emittedUrl).toBe('https://example.com/')
})
})
describe('custom validateUrlFn', () => {
it('uses custom validateUrlFn when provided', async () => {
const customValidator = vi.fn().mockResolvedValue(true)
renderUrlInput('https://custom.example', customValidator)
await waitFor(() => {
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
})
expect(checkUrlReachable).not.toHaveBeenCalled()
})
})
describe('state-change emission', () => {
it('emits state-change when validation state changes', async () => {
const onStateChange = vi.fn()
vi.mocked(checkUrlReachable).mockResolvedValue(true)
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onState-change': onStateChange
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
})
})
})
})

View File

@@ -1,112 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
getPlatform: vi.fn().mockReturnValue('win32')
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
te: () => false,
st: (_key: string, fallback: string) => fallback
}))
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import GpuPicker from '@/components/install/GpuPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} }
})
const HardwareOptionStub = {
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
emits: ['click'],
template:
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
}
function renderPicker(device: TorchDeviceType | null = null) {
return render(GpuPicker, {
props: { device },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
HardwareOption: HardwareOptionStub,
Tag: {
props: ['value'],
template: '<span data-testid="recommended-tag">{{ value }}</span>'
}
}
}
})
}
describe('GpuPicker', () => {
describe('recommended badge', () => {
it('shows recommended badge for nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('shows recommended badge for amd', () => {
renderPicker('amd')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('does not show recommended badge for cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge for unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge when no device is selected', () => {
renderPicker(null)
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
})
describe('selection state', () => {
it('marks nvidia as selected when device is nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
})
it('marks cpu as selected when device is cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
})
it('marks unsupported as selected when device is unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
})
it('no option is selected when device is null', () => {
renderPicker(null)
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
})
})
describe('gpu options on non-darwin platform', () => {
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
renderPicker(null)
expect(screen.getByTestId('NVIDIA')).toBeDefined()
expect(screen.getByTestId('AMD')).toBeDefined()
expect(screen.getByTestId('CPU')).toBeDefined()
expect(screen.getByTestId('Manual Install')).toBeDefined()
})
})
})

View File

@@ -4,7 +4,7 @@
<button
:class="
cn(
'hardware-option flex h-[190px] w-[170px] flex-col items-center rounded-3xl border-4 bg-neutral-900/70 p-5 transition-all duration-200',
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@@ -12,13 +12,13 @@
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container flex h-[110px] w-[110px] shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800"
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="size-full object-cover"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
@@ -28,7 +28,7 @@
</div>
<!-- Text Content -->
<div v-if="subtitle" class="mt-4 text-center">
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string

View File

@@ -1,223 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createI18n } from 'vue-i18n'
const mockValidateComfyUISource = vi.fn()
const mockShowDirectoryPicker = vi.fn()
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
validateComfyUISource: mockValidateComfyUISource,
showDirectoryPicker: mockShowDirectoryPicker
}))
}))
import MigrationPicker from '@/components/install/MigrationPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
install: {
migrationSourcePathDescription: 'Source path description',
migrationOptional: 'Migration is optional',
selectItemsToMigrate: 'Select items to migrate',
pathValidationFailed: 'Validation failed',
failedToSelectDirectory: 'Failed to select directory',
locationPicker: {
migrationPathPlaceholder: 'Enter path'
}
}
}
}
})
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue'],
template: `<input
data-testid="source-input"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>`
}
const CheckboxStub = {
props: ['modelValue', 'inputId', 'binary'],
emits: ['update:modelValue', 'click'],
template: `<input
type="checkbox"
:data-testid="'checkbox-' + inputId"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
@click.stop="$emit('click')"
/>`
}
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
return render(MigrationPicker, {
props: { sourcePath, migrationItemIds },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: {
props: ['severity'],
template: '<div data-testid="error-msg"><slot /></div>'
}
}
}
})
}
describe('MigrationPicker', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('isValidSource', () => {
it('hides migration options when source path is empty', () => {
renderPicker('')
expect(screen.queryByText('Select items to migrate')).toBeNull()
})
it('shows migration options when source path is valid', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const { rerender } = renderPicker('')
await rerender({ sourcePath: '/valid/path' })
await waitFor(() => {
expect(screen.getByText('Select items to migrate')).toBeDefined()
})
})
it('shows optional message when no valid source', () => {
renderPicker('')
expect(screen.getByText('Migration is optional')).toBeDefined()
})
})
describe('validateSource', () => {
it('clears error when source path becomes empty', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
await user.clear(screen.getByTestId('source-input'))
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
it('shows error message when validation fails', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Path not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
})
it('shows no error when validation passes', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/valid/path')
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
})
describe('migrationItemIds watchEffect', () => {
it('emits all item IDs by default (all items start selected)', async () => {
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
migrationItemIds: [],
'onUpdate:migrationItemIds': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button />' },
Message: { template: '<div />' }
}
}
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
const emittedIds = onUpdate.mock.calls[0][0]
expect(Array.isArray(emittedIds)).toBe(true)
expect(emittedIds.length).toBeGreaterThan(0)
})
})
})
describe('browse path', () => {
it('opens directory picker on browse click', async () => {
mockShowDirectoryPicker.mockResolvedValue(null)
renderPicker()
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
})
it('updates source path when directory is selected', async () => {
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
'onUpdate:sourcePath': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: { template: '<div />' }
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
})
})
})
})

View File

@@ -1,85 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import StatusTag from '@/components/maintenance/StatusTag.vue'
const TagStub = defineComponent({
name: 'Tag',
props: {
icon: String,
severity: String,
value: String
},
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
})
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
return render(StatusTag, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: { Tag: TagStub }
}
})
}
describe('StatusTag', () => {
describe('refreshing state', () => {
it('shows info severity when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
})
it('shows refreshing translation key when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
it('shows question icon when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
})
})
describe('error state', () => {
it('shows danger severity when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
})
it('shows error translation key when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
})
})
describe('OK state', () => {
it('shows success severity when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
})
it('shows OK translation key when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
})
})
describe('precedence', () => {
it('shows refreshing state when both refreshing and error are true', () => {
renderStatusTag({ error: true, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
})
})

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskCard from '@/components/maintenance/TaskCard.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
shortDescription: 'Short description',
errorDescription: 'Error occurred',
execute: vi.fn().mockResolvedValue(true)
}
const cardStubs = {
Card: {
template: '<div data-testid="card"><slot name="content"></slot></div>'
},
Button: { template: '<button />' }
}
function renderCard(
state: 'OK' | 'error' | 'warning' | 'skipped',
task: MaintenanceTask = baseTask
) {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskCard, {
props: { task },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: cardStubs
}
})
}
describe('TaskCard', () => {
describe('description computed', () => {
it('shows errorDescription when task state is error', () => {
renderCard('error')
expect(screen.getByText('Error occurred')).toBeDefined()
})
it('shows shortDescription when task state is OK', () => {
renderCard('OK')
expect(screen.getByText('Short description')).toBeDefined()
})
it('shows shortDescription when task state is warning', () => {
renderCard('warning')
expect(screen.getByText('Short description')).toBeDefined()
})
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
const taskWithoutErrorDesc: MaintenanceTask = {
...baseTask,
errorDescription: undefined
}
renderCard('error', taskWithoutErrorDesc)
expect(screen.getByText('Short description')).toBeDefined()
})
})
})

View File

@@ -64,7 +64,7 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()

View File

@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
button: { text: 'Fix', icon: 'pi pi-check' },
execute: vi.fn().mockResolvedValue(true)
}
const ButtonStub = {
props: ['severity', 'label', 'icon', 'loading'],
template:
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
}
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskListItem, {
props: { task: baseTask },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
Button: ButtonStub,
Popover: { template: '<div />' },
TaskListStatusIcon: { template: '<span />' }
}
}
})
}
describe('TaskListItem', () => {
describe('severity computed', () => {
it('uses primary severity for error state', () => {
renderItem('error')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses primary severity for warning state', () => {
renderItem('warning')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses secondary severity for OK state', () => {
renderItem('OK')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
it('uses secondary severity for skipped state', () => {
renderItem('skipped')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
})
})

View File

@@ -1,44 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
function renderIcon(state: TaskState, loading?: boolean) {
return render(TaskListStatusIcon, {
props: { state, loading },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
ProgressSpinner: {
template: '<div data-testid="spinner" />'
}
}
}
})
}
describe('TaskListStatusIcon', () => {
describe('loading / no state', () => {
it('renders spinner when state is undefined', () => {
renderIcon(undefined)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('renders spinner when loading is true', () => {
renderIcon('OK', true)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('hides spinner when state is defined and not loading', () => {
renderIcon('OK', false)
expect(screen.queryByTestId('spinner')).toBeNull()
})
})
})

View File

@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
() => {
const mockTerminal = {
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
hasSelection: vi.fn<[], boolean>(),
resize: vi.fn(),
cols: 80,
rows: 24
}
const MockTerminal = vi.fn(function () {
return mockTerminal
})
const mockFitAddon = {
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
}
const MockFitAddon = vi.fn(function () {
return mockFitAddon
})
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
}
)
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
import { withSetup } from '@/test/withSetup'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
function getKeyHandler(): (event: KeyboardEvent) => boolean {
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
}
describe('useTerminal key event handler', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTerminal.hasSelection.mockReturnValue(false)
const element = ref<HTMLElement | undefined>(undefined)
withSetup(() => useTerminal(element))
})
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle copy when text is selected (Meta+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not pass copy to browser when no text is selected', () => {
mockTerminal.hasSelection.mockReturnValue(false)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('allows browser to handle paste (Ctrl+V)', () => {
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle paste (Meta+V)', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not intercept non-keydown events', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keyup',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('passes through unrelated key combinations', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: false,
key: 'Enter'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
})

View File

@@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
const mockSerialize = vi.fn<[], string>()
const MockSerializeAddon = vi.fn(function () {
return { serialize: mockSerialize }
})
return { mockSerialize, MockSerializeAddon }
})
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn(function () {
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
})
}))
vi.mock('@xterm/addon-serialize', () => ({
SerializeAddon: MockSerializeAddon
}))
import type { Terminal } from '@xterm/xterm'
import { withSetup } from '@/test/withSetup'
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
describe('useTerminalBuffer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSerialize.mockReturnValue('')
})
describe('copyTo', () => {
it('writes serialized buffer content to the destination terminal', () => {
mockSerialize.mockReturnValue('hello world')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('hello world')
})
it('writes empty string when buffer is empty', () => {
mockSerialize.mockReturnValue('')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('')
})
})
})

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
describe('getDialog', () => {
it('returns the matching dialog for a valid ID', () => {
const result = getDialog('reinstallVenv')
expect(result.id).toBe('reinstallVenv')
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
})
it('returns invalidDialog for an unknown string ID', () => {
const result = getDialog('unknownDialog')
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog when given an array of strings', () => {
const result = getDialog(['reinstallVenv', 'other'])
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog for empty string', () => {
const result = getDialog('')
expect(result.id).toBe('invalidDialog')
})
it('returns a deep clone — mutations do not affect the original', () => {
const result = getDialog('reinstallVenv')
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
result.buttons[0].label = 'Mutated'
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
originalFirstLabel
)
})
it('every button has a returnValue', () => {
for (const id of Object.keys(DESKTOP_DIALOGS)) {
const result = getDialog(id)
for (const button of result.buttons) {
expect(button.returnValue).toBeDefined()
}
}
})
it('invalidDialog has a close/cancel button', () => {
const result = getDialog('invalidDialog')
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
})
})

View File

@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
setBasePath: vi.fn(),
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
uv: {
installRequirements: vi.fn<[], Promise<void>>(),
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
function findTask(id: string) {
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
if (!task) throw new Error(`Task not found: ${id}`)
return task
}
describe('desktopMaintenanceTasks', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(window, 'open').mockReturnValue(null)
mockElectron.reinstall.mockResolvedValue(undefined)
mockElectron.uv.clearCache.mockResolvedValue(undefined)
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
})
describe('pythonPackages', () => {
it('returns true when installation succeeds', async () => {
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
expect(await findTask('pythonPackages').execute()).toBe(true)
})
it('returns false when installation throws', async () => {
mockElectron.uv.installRequirements.mockRejectedValue(
new Error('install failed')
)
expect(await findTask('pythonPackages').execute()).toBe(false)
})
})
describe('URL-opening tasks', () => {
it('git execute opens the git download page', () => {
findTask('git').execute()
expect(window.open).toHaveBeenCalledWith(
'https://git-scm.com/downloads/',
'_blank'
)
})
it('uv execute opens the uv installation page', () => {
findTask('uv').execute()
expect(window.open).toHaveBeenCalledWith(
'https://docs.astral.sh/uv/getting-started/installation/',
'_blank'
)
})
it('vcRedist execute opens the VC++ redistributable download', () => {
findTask('vcRedist').execute()
expect(window.open).toHaveBeenCalledWith(
'https://aka.ms/vs/17/release/vc_redist.x64.exe',
'_blank'
)
})
})
})

View File

@@ -1,288 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron, testTasks } = vi.hoisted(() => {
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
const basicTaskExecute = vi.fn().mockResolvedValue(true)
return {
mockElectron: {
Validation: {
validateInstallation: vi.fn()
}
},
testTasks: [
{
id: 'basicTask',
name: 'Basic Task',
execute: basicTaskExecute
},
{
id: 'terminalTask',
name: 'Terminal Task',
execute: terminalTaskExecute,
usesTerminal: true,
isInstallationFix: true
}
]
}
})
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: testTasks
}))
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
type PartialInstallValidation = Partial<InstallValidation> &
Record<string, unknown>
function makeUpdate(
overrides: PartialInstallValidation = {}
): InstallValidation {
return {
inProgress: false,
installState: 'installed',
...overrides
} as InstallValidation
}
function createStore() {
setActivePinia(createTestingPinia({ stubActions: false }))
return useMaintenanceTaskStore()
}
describe('useMaintenanceTaskStore', () => {
let store: ReturnType<typeof useMaintenanceTaskStore>
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
beforeEach(() => {
vi.resetAllMocks()
store = createStore()
})
describe('processUpdate', () => {
it('sets isRefreshing to true during in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.isRefreshing).toBe(true)
})
it('sets isRefreshing to false when update is complete', () => {
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
expect(store.isRefreshing).toBe(false)
})
it('updates runner state for tasks present in the final update', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).state).toBe('error')
})
it('sets task state to warning from update', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
expect(store.getRunner(basicTask).state).toBe('warning')
})
it('marks runners as refreshing when task id is absent from in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.getRunner(basicTask).refreshing).toBe(true)
})
it('marks task as skipped when absent from final update', () => {
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).state).toBe('skipped')
})
it('clears refreshing flag after final update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).refreshing).toBe(false)
})
it('stores lastUpdate and exposes unsafeBasePath', () => {
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
expect(store.unsafeBasePath).toBe(true)
})
it('exposes unsafeBasePathReason from the update', () => {
store.processUpdate(
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
)
expect(store.unsafeBasePathReason).toBe('oneDrive')
})
})
describe('anyErrors', () => {
it('returns true when any task has error state', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.anyErrors).toBe(true)
})
it('returns false when all tasks are OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
expect(store.anyErrors).toBe(false)
})
it('returns false when all tasks are warning', () => {
store.processUpdate(
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
)
expect(store.anyErrors).toBe(false)
})
})
describe('runner state transitions', () => {
it('marks runner as resolved when transitioning from error to OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
})
it('does not mark resolved for warning to OK transition', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
it('clears resolved flag when task returns to error', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('clearResolved', () => {
it('clears resolved flags on all runners', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
store.clearResolved()
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('execute', () => {
it('returns true when task execution succeeds', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
const result = await store.execute(basicTask)
expect(result).toBe(true)
})
it('returns false when task execution fails', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(false)
const result = await store.execute(basicTask)
expect(result).toBe(false)
})
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(true)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).toHaveBeenCalledOnce()
})
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(false)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('sets runner executing to true during task execution', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(basicTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(basicTask)
expect(store.getRunner(basicTask).executing).toBe(true)
resolveTask(true)
await executePromise
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('clears executing flag when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow('fail')
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('sets runner error message when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(
new Error('something broke')
)
await expect(store.execute(basicTask)).rejects.toThrow()
expect(store.getRunner(basicTask).error).toBe('something broke')
})
it('clears runner error on successful execution', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow()
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(store.getRunner(basicTask).error).toBeUndefined()
})
})
describe('isRunningTerminalCommand', () => {
it('returns true while a terminal task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningTerminalCommand).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningTerminalCommand).toBe(false)
})
it('returns false when no terminal tasks are executing', () => {
expect(store.isRunningTerminalCommand).toBe(false)
})
})
describe('isRunningInstallationFix', () => {
it('returns true while an installation-fix task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningInstallationFix).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningInstallationFix).toBe(false)
})
})
})

View File

@@ -1 +0,0 @@
import '@testing-library/jest-dom/vitest'

View File

@@ -1,16 +0,0 @@
import { render } from '@testing-library/vue'
import { defineComponent } from 'vue'
export function withSetup<T>(composable: () => T): T {
let result!: T
render(
defineComponent({
setup() {
result = composable()
return {}
},
template: '<div />'
})
)
return result
}

View File

@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
NetWork: {
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
describe('checkMirrorReachable', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false for an invalid URL without calling canAccessUrl', async () => {
const result = await checkMirrorReachable('not-a-url')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
it('returns false when canAccessUrl returns false', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(false)
})
it('returns true when URL is valid and canAccessUrl returns true', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(true)
})
it('passes the mirror URL to canAccessUrl', async () => {
const url = 'https://pypi.org/simple/'
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
await checkMirrorReachable(url)
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
})
it('returns false for empty string', async () => {
const result = await checkMirrorReachable('')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
})

View File

@@ -1,72 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isElectron, isNativeWindow } from '@/utils/envUtil'
describe('isElectron', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when window.electronAPI is an object', () => {
vi.stubGlobal('window', { ...window, electronAPI: {} })
expect(isElectron()).toBe(true)
})
it('returns false when window.electronAPI is undefined', () => {
vi.stubGlobal('window', { ...window, electronAPI: undefined })
expect(isElectron()).toBe(false)
})
it('returns false when window.electronAPI is absent', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isElectron()).toBe(false)
})
})
describe('isNativeWindow', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: true }
}
})
expect(isNativeWindow()).toBe(true)
})
it('returns false when not in Electron', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay.visible is false', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: false }
}
})
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay is absent', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: { ...window.navigator, windowControlsOverlay: undefined }
})
expect(isNativeWindow()).toBe(false)
})
})

View File

@@ -1,102 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, ref } from 'vue'
import { withSetup } from '@/test/withSetup'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
describe('useMinLoadingDurationRef', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects false when source is initially false', () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(false)
})
it('reflects true when source is initially true', () => {
const source = ref(true)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(true)
})
it('becomes true immediately when source transitions to true', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('stays true within minDuration after source returns to false', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
expect(result.value).toBe(true)
})
it('becomes false after minDuration has elapsed', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(250)
await nextTick()
expect(result.value).toBe(false)
})
it('remains true while source is true even after minDuration elapses', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
vi.advanceTimersByTime(500)
await nextTick()
expect(result.value).toBe(true)
})
it('works with a computed ref as input', async () => {
const raw = ref(false)
const source = computed(() => raw.value)
const result = withSetup(() => useMinLoadingDurationRef(source))
raw.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('uses 250ms as default minDuration', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(249)
await nextTick()
expect(result.value).toBe(true)
vi.advanceTimersByTime(1)
await nextTick()
expect(result.value).toBe(false)
})
})

View File

@@ -0,0 +1 @@
export { cn } from '@comfyorg/tailwind-utils'

View File

@@ -13,8 +13,7 @@
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts",
"vite.config.mts",
"vitest.config.mts"
"vite.config.mts"
],
"references": []
}

View File

@@ -1,22 +0,0 @@
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(projectRoot, 'src'),
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
}
},
test: {
globals: true,
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
setupFiles: ['./src/test/setup.ts']
}
})

View File

@@ -1,7 +1,2 @@
dist/
.astro/
test-results/
playwright-report/
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

View File

@@ -1,20 +1,18 @@
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
integrations: [vue()],
vite: {
plugins: [tailwindcss()],
server: {
watch: {
ignored: ['**/playwright-report/**']
}
}
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 B

View File

@@ -1,143 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /The full power of/i, level: 1 })
).toBeVisible()
await expect(
page.getByText(/The easiest way to start with ComfyUI/)
).toBeVisible()
})
test('HeroSection has CTA button linking to cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /TRY COMFY CLOUD FOR FREE/i })
await expect(cta).toBeVisible()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Powerful GPUs',
'All models',
'More control',
'Community workflows'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
await expect(heading).toBeVisible()
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {
const cta = page.getByRole('link', {
name: /EXPLORE WORKFLOWS/i
})
await expect(cta.first()).toBeVisible()
await expect(cta.first()).toHaveAttribute(
'href',
'https://comfy.org/workflows'
)
})
test('AudienceSection heading and cards are visible', async ({ page }) => {
await expect(page.getByText(/creators/i).first()).toBeVisible()
for (const label of ['CREATORS', 'TEAMS & STUDIOS']) {
await expect(page.getByText(label).first()).toBeVisible()
}
})
test('PricingSection heading and CTA are visible', async ({ page }) => {
await expect(page.getByText(/Simple, credit-based pricing/)).toBeVisible()
const cta = page.getByRole('link', { name: /SEE PRICING PLANS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', '/cloud/pricing')
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('FAQSection heading is visible with 15 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(15)
})
})
test.describe('Cloud FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
})

View File

@@ -1,196 +0,0 @@
import { devices, expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ browser }) => {
const context = await browser.newContext({ userAgent: WINDOWS_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Unlimited',
'Any model',
'Your machine',
'Free. Open Source'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('EcoSystemSection heading is visible', async ({ page }) => {
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('ProductCardsSection links to cloud, api, enterprise', async ({
page
}) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
test('FAQSection heading is visible with 8 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(8)
})
})
test.describe('FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeHidden()
})
test('clicking a collapsed FAQ expands it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
})
})
test.describe('Download page mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('CloudBannerSection is visible', async ({ page }) => {
await expect(page.getByText(/Need more power/)).toBeVisible()
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ browser }) => {
const context = await browser.newContext({
...devices['Pixel 5'],
userAgent: WINDOWS_UA
})
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()
await expect(githubBtn).toBeVisible()
await expect
.poll(async () => {
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
if (!downloadBox || !githubBox) return false
return githubBox.y > downloadBox.y
})
.toBe(true)
await context.close()
})
})

View File

@@ -1,47 +0,0 @@
import { fileURLToPath } from 'node:url'
import type { Route } from '@playwright/test'
import { test as base } from '@playwright/test'
function assetPath(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url))
}
const IMAGE_PLACEHOLDER = assetPath('../assets/placeholder-1x1.webp')
const VIDEO_PLACEHOLDER = assetPath('../assets/placeholder.webm')
const ANALYTICS_PATTERN = '**/va.vercel-scripts.com/**' as const
const MEDIA_PATTERN =
/^https:\/\/media\.comfy\.org\/.*\.(webp|webm|mp4|png|jpg|jpeg|vtt)(\?.*)?$/i
const VIDEO_PATTERN = /\.(webm|mp4)(\?|$)/i
const SUBTITLE_PATTERN = /\.vtt(\?|$)/i
function blockAnalytics(route: Route) {
return route.abort('blockedbyclient')
}
async function fulfillMedia(route: Route) {
const url = route.request().url()
if (VIDEO_PATTERN.test(url))
return route.fulfill({ path: VIDEO_PLACEHOLDER, status: 200 })
if (SUBTITLE_PATTERN.test(url))
return route.fulfill({
status: 200,
contentType: 'text/vtt',
body: 'WEBVTT\n'
})
await route.fulfill({ path: IMAGE_PLACEHOLDER, status: 200 })
}
export const test = base.extend<{ blockExternalMedia: void }>({
blockExternalMedia: [
async ({ page }, use) => {
await page.route(ANALYTICS_PATTERN, blockAnalytics)
await page.route(MEDIA_PATTERN, fulfillMedia)
await use()
},
{ auto: true }
]
})

View File

@@ -1,180 +0,0 @@
import { fileURLToPath } from 'node:url'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const caseStudyVideoPath = fileURLToPath(
new URL(
'../../../public/assets/images/cloud-subscription.webm',
import.meta.url
)
)
test.describe('Homepage @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Professional Control/i, level: 1 })
).toBeVisible()
})
test('SocialProofBar logos are visible', async ({ page }) => {
await expect(
page.locator('img[src*="/icons/clients/"]').first()
).toBeVisible()
})
test('ProductShowcase section is visible', async ({ page }) => {
await expect(page.getByText('HOW', { exact: true }).first()).toBeVisible()
await expect(
page.getByText(/Connect models, processing steps, and outputs/)
).toBeVisible()
})
test('UseCaseSection is visible', async ({ page }) => {
await expect(
page.getByText('Industries that create with ComfyUI')
).toBeVisible()
})
test('GetStartedSection with heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Get started in minutes' })
).toBeVisible()
})
test('ProductCardsSection has 4 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
})
test('CaseStudySpotlight section is visible', async ({ page }) => {
const section = page.locator('section', {
has: page.getByText('Customer Stories')
})
await expect(section).toBeVisible()
await expect(
section.getByRole('heading', { name: /See Comfy/i })
).toBeVisible()
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()
})
})
test.describe('Product showcase accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('first feature is active by default', async ({ page }) => {
await expect(
page.getByText(/Build powerful AI pipelines by connecting nodes/).first()
).toBeVisible()
})
test('clicking inactive feature expands it and collapses previous', async ({
page
}) => {
const secondFeature = page
.getByRole('button', { name: /App mode/i })
.first()
await secondFeature.scrollIntoViewIfNeeded()
await secondFeature.click()
await expect(
secondFeature.getByText(/If you are new to ComfyUI/)
).toBeVisible()
const firstFeature = page
.getByRole('button', { name: /Full Control with Nodes/i })
.first()
await expect(firstFeature).not.toHaveClass(/bg-primary-comfy-yellow/)
await expect(secondFeature).toHaveClass(/bg-primary-comfy-yellow/)
})
})
test.describe('Video player @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route(
'https://media.comfy.org/website/customers/blackmath/video.webm',
(route) =>
route.fulfill({
contentType: 'video/webm',
path: caseStudyVideoPath
})
)
await page.goto('/')
})
test('clicking play advances playback', async ({ page }) => {
const section = page.locator('section', {
has: page.getByText('Customer Stories')
})
const video = section.locator('video')
await expect
.poll(
async () =>
video.evaluate((element: HTMLVideoElement) => element.duration),
{ timeout: 15_000 }
)
.toBeGreaterThan(0)
await section.getByRole('button', { name: 'Play' }).click()
await expect
.poll(async () =>
video.evaluate((element: HTMLVideoElement) => element.currentTime)
)
.toBeGreaterThan(0)
})
})
test.describe('Product cards links @smoke', () => {
test('cards have correct hrefs', async ({ page }) => {
await page.goto('/')
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/download', '/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
})
test.describe('Get started section links @smoke', () => {
test('has download and cloud links', async ({ page }) => {
await page.goto('/')
const section = page.locator('section', {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Local' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')
const cloudLink = section.getByRole('link', { name: 'Launch Cloud' })
await expect(cloudLink).toBeVisible()
await expect(cloudLink).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})

View File

@@ -1,163 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('logo links to homepage', async ({ page }) => {
const logo = page.getByRole('link', { name: 'Comfy home' })
await expect(logo).toBeVisible()
await expect(logo).toHaveAttribute('href', '/')
})
test('has all top-level nav items', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of [
'PRODUCTS',
'PRICING',
'COMMUNITY',
'RESOURCES',
'COMPANY'
]) {
await expect(desktopLinks.getByText(label).first()).toBeVisible()
}
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
).toBeVisible()
})
})
test.describe('Desktop dropdown @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hovering PRODUCTS shows dropdown items', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: /PRODUCTS/i
})
await productsButton.hover()
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Local',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
]) {
await expect(dropdown.getByText(item)).toBeVisible()
}
})
test('moving mouse away closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
await expect(comfyLocal).toBeHidden()
})
test('Escape key closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
await expect(comfyLocal).toBeHidden()
})
})
test.describe('Mobile menu @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger button is visible', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('clicking hamburger opens mobile menu with nav items', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(menu).toBeVisible()
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
await expect(menu.getByText(label).first()).toBeVisible()
}
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
})
test('CTA buttons visible in mobile menu', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})
})
test.describe('Footer @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('footer is visible with link sections', async ({ page }) => {
const footer = page.locator('footer')
await expect(footer).toBeVisible()
for (const heading of ['Products', 'Resources', 'Company']) {
await expect(
footer.getByRole('heading', { name: heading }).first()
).toBeVisible()
}
})
test('copyright text is visible', async ({ page }) => {
await expect(
page.locator('footer').getByText(/© \d{4} Comfy Org/)
).toBeVisible()
})
})

View File

@@ -1,50 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop layout @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('navigation links visible and hamburger hidden', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await expect(desktopLinks.getByText('PRODUCTS').first()).toBeVisible()
await expect(desktopLinks.getByText('PRICING').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Toggle menu' })).toBeHidden()
})
test('product cards in grid layout', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
expect(firstBox, 'first card bounding box').not.toBeNull()
expect(secondBox, 'second card bounding box').not.toBeNull()
expect(firstBox!.y).toBeCloseTo(secondBox!.y, 0)
})
})
test.describe('Mobile layout @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger visible and desktop nav hidden', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('SocialProofBar shows two marquee rows on mobile', async ({ page }) => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
})

View File

@@ -1,6 +0,0 @@
export const VIEWPORTS = [
{ name: '1-sm', width: 393, height: 851 },
{ name: '2-md', width: 768, height: 1024 },
{ name: '3-lg', width: 1280, height: 800 },
{ name: '4-xl', width: 1536, height: 864 }
] as const

View File

@@ -1,159 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
import { VIEWPORTS } from './viewports'
test.describe.configure({ timeout: 60_000 })
const SMALL_VIEWPORTS = VIEWPORTS.filter(
(v) => v.name === '1-sm' || v.name === '2-md'
)
async function assertNoOverflow(page: Page) {
await expect
.poll(
() =>
page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth
),
{ message: 'page has horizontal overflow', timeout: 5_000 }
)
.toBe(false)
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {
for (const vp of VIEWPORTS) {
test.describe(vp.name, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/')
})
test('product-cards screenshot', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`home-product-cards-${vp.name}.png`)
})
test('get-started screenshot', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Get started/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`home-get-started-${vp.name}.png`)
})
})
}
})
test.describe('Pricing', { tag: '@visual' }, () => {
for (const vp of VIEWPORTS) {
test(`pricing-tiers-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/cloud/pricing')
await assertNoOverflow(page)
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`pricing-tiers-${vp.name}.png`)
})
}
})
test.describe('Contact', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`form-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/contact')
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Create powerful workflows/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`contact-form-${vp.name}.png`)
})
}
})
test.describe('Gallery', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`gallery-grid-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/gallery')
const section = page.getByTestId('gallery-grid')
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`gallery-grid-${vp.name}.png`)
})
}
})
test.describe('About', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`hero-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/about')
const hero = page.locator('section', {
has: page.getByRole('heading', { name: /Build the tools/i })
})
await expect(hero).toBeVisible()
await hero.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`about-hero-${vp.name}.png`)
})
}
})
const OVERFLOW_SKIP = new Set([
'/ 3-lg',
'/ 4-xl',
'/cloud 2-md',
'/cloud 3-lg',
'/cloud 4-xl',
'/download 2-md',
'/download 3-lg',
'/download 4-xl'
])
test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/pricing',
'/contact',
'/download',
'/gallery',
'/about',
'/careers'
]
for (const url of pages) {
for (const vp of VIEWPORTS) {
const key = `${url} ${vp.name}`
test(`${url} ${vp.name} no overflow`, async ({ page }) => {
test.skip(OVERFLOW_SKIP.has(key), 'Known overflow bug at this viewport')
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto(url)
await assertNoOverflow(page)
})
}
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -5,30 +5,16 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"dev:no-toolbar": "cross-env NO_TOOLBAR=1 astro dev",
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots"
"preview": "astro preview"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@astrojs/vue": "catalog:",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
@@ -88,16 +74,6 @@
"cwd": "apps/website",
"command": "astro check"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}

View File

@@ -1,57 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
? {
timeout: 30_000,
retries: 0,
workers: 1,
use: {
baseURL: 'http://localhost:4321',
trace: 'on',
video: 'on'
}
}
: {
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:4321',
trace: 'on-first-retry'
}
}
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.CI
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {
command: 'pnpm preview',
port: 4321,
reuseExistingServer: !process.env.CI
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'] },
grepInvert: /@mobile|@visual/
},
{
name: 'mobile',
use: { ...devices['Pixel 5'] },
grep: /@mobile/
},
{
name: 'visual',
use: { ...devices['Desktop Chrome'] },
grep: /@visual/,
fullyParallel: false
}
]
})

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