mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-10 14:29:59 +00:00
Compare commits
65 Commits
fix/subgra
...
perf/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89f675113 | ||
|
|
49d2cbd1fa | ||
|
|
d4b2264091 | ||
|
|
96da215bb8 | ||
|
|
483d14d120 | ||
|
|
eadb86122a | ||
|
|
5a35be7d7a | ||
|
|
0132c77c7d | ||
|
|
63eab15c4f | ||
|
|
277ee5c32e | ||
|
|
e8787dee9d | ||
|
|
ba0bab3e50 | ||
|
|
bbb07053c4 | ||
|
|
97fca566fb | ||
|
|
c6b8883e61 | ||
|
|
8487c13f14 | ||
|
|
809da9c11c | ||
|
|
65d1313443 | ||
|
|
f90d6cf607 | ||
|
|
2c34d955cb | ||
|
|
8b6c1b3649 | ||
|
|
026aeb71b2 | ||
|
|
d96a7d2b32 | ||
|
|
1720aa0286 | ||
|
|
c671a33182 | ||
|
|
25d1ac7456 | ||
|
|
2189172f15 | ||
|
|
9b769656ac | ||
|
|
934f1487bd | ||
|
|
6f98fe5ba7 | ||
|
|
44c3d08b56 | ||
|
|
537e4bc4f2 | ||
|
|
4b0b8e7240 | ||
|
|
3b78dfbe1c | ||
|
|
036be1c7e9 | ||
|
|
b494392265 | ||
|
|
3f375bea9c | ||
|
|
c084089fc8 | ||
|
|
4cb83353cb | ||
|
|
d73c4406ed | ||
|
|
ccdde8697c | ||
|
|
194baf7aee | ||
|
|
5770837e07 | ||
|
|
4078f8be8f | ||
|
|
ff0453416a | ||
|
|
fd9c67ade8 | ||
|
|
83f4e7060a | ||
|
|
31c789c242 | ||
|
|
1b05927ff4 | ||
|
|
97853aa8b0 | ||
|
|
ac922fe6aa | ||
|
|
6f8e58bfa5 | ||
|
|
6cd3b59d5f | ||
|
|
0b83926c3e | ||
|
|
858946b0f5 | ||
|
|
c5b183086d | ||
|
|
5872885cc5 | ||
|
|
a8d23275d9 | ||
|
|
84f401bbe9 | ||
|
|
0b689a3c3c | ||
|
|
32ff1a5bdb | ||
|
|
0a5f281291 | ||
|
|
61af482cd4 | ||
|
|
3a73ce72bb | ||
|
|
f3cbbb8654 |
@@ -11,10 +11,11 @@ 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. **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`)
|
||||
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
|
||||
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
6. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
7. **Log & Report** — Generate session report (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
@@ -37,16 +38,29 @@ 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 | App mode, cloud auth, cloud-specific UI |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ------------------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
|
||||
|
||||
**⚠️ 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:
|
||||
### What Goes Where
|
||||
|
||||
- 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)
|
||||
**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.
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
@@ -67,6 +81,32 @@ 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.**
|
||||
@@ -77,6 +117,8 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
|
||||
| **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 |
|
||||
|
||||
@@ -103,7 +145,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** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
|
||||
- **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)
|
||||
|
||||
## Wave Verification
|
||||
|
||||
@@ -122,6 +164,18 @@ 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.
|
||||
@@ -135,6 +189,43 @@ 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)
|
||||
@@ -150,13 +241,96 @@ 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, push, create PR, merge
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -23,10 +23,10 @@ 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 (app mode, cloud auth, cloud billing, cloud-specific UI) |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
| 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. |
|
||||
| `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,8 +61,6 @@ done
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Present decisions.md before execution. Include:
|
||||
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.
|
||||
|
||||
1. All MUST/SHOULD/SKIP categorizations with rationale
|
||||
2. Questions for human (feature existence, scope, deps)
|
||||
3. Estimated effort per branch
|
||||
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
|
||||
|
||||
@@ -73,14 +73,22 @@ 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
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
|
||||
# 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
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
@@ -114,7 +122,30 @@ source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm tes
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
|
||||
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.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
@@ -142,7 +173,35 @@ git rm $FILE
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Locale Files
|
||||
### 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
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
@@ -176,8 +235,14 @@ 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. **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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -2,26 +2,25 @@
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
|
||||
|
||||
```markdown
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------ | ----------- | ------- |
|
||||
| #XXXX | Title | merged | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave:
|
||||
Track verification results per wave within execution-log.md:
|
||||
|
||||
```markdown
|
||||
## Wave N Verification — TARGET_BRANCH
|
||||
Wave N Verification -- TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Unit tests: ✅ Pass / ❌ Fail
|
||||
- Typecheck: pass / fail
|
||||
- Fix PR: #YYYY (if needed)
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
@@ -63,40 +62,42 @@ Track verification results per wave:
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverable: Visual Summary
|
||||
## Final Deliverables
|
||||
|
||||
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.
|
||||
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph branch1["☁️ cloud/X.XX — N PRs"]
|
||||
C1["#XXXX title"]
|
||||
C2["#XXXX title"]
|
||||
end
|
||||
### 1. execution-log.md (internal)
|
||||
|
||||
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
|
||||
M1["#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 branch2should["🟡 core/X.XX SHOULD — N PRs"]
|
||||
S1["#XXXX-#XXXX N auto-merged"]
|
||||
S2["#XXXX-#XXXX N manual picks"]
|
||||
end
|
||||
### 2. backport-author-accountability.md (Slack-compatible)
|
||||
|
||||
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
|
||||
```
|
||||
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
|
||||
|
||||
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
|
||||
### 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.
|
||||
|
||||
## Files to Track
|
||||
|
||||
- `candidate_list.md` — all candidates per branch
|
||||
- `decisions.md` — MUST/SHOULD/SKIP with rationale
|
||||
- `wave-plan.md` — execution order
|
||||
- `execution-log.md` — real-time status
|
||||
- `backport-session-report.md` — final summary
|
||||
All in `~/temp/backport-session/`:
|
||||
|
||||
All in `~/temp/backport-session/`.
|
||||
- `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)
|
||||
|
||||
246
.claude/skills/hardening-flaky-e2e-tests/SKILL.md
Normal file
246
.claude/skills/hardening-flaky-e2e-tests/SKILL.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
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.
|
||||
107
.github/workflows/api-update-registry-api-types.yaml
vendored
107
.github/workflows/api-update-registry-api-types.yaml
vendored
@@ -1,107 +0,0 @@
|
||||
# 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
|
||||
1
.github/workflows/ci-tests-unit.yaml
vendored
1
.github/workflows/ci-tests-unit.yaml
vendored
@@ -31,4 +31,5 @@ jobs:
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
33
.github/workflows/ci-website-build.yaml
vendored
Normal file
33
.github/workflows/ci-website-build.yaml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
14
.github/workflows/release-version-bump.yaml
vendored
14
.github/workflows/release-version-bump.yaml
vendored
@@ -142,6 +142,20 @@ 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
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
@@ -104,8 +105,7 @@
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -179,6 +179,12 @@ 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.
|
||||
@@ -226,6 +232,7 @@ 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>
|
||||
|
||||
@@ -62,6 +62,37 @@ 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`.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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',
|
||||
integrations: [vue()],
|
||||
integrations: [vue(), sitemap()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"vue": "catalog:"
|
||||
|
||||
4
apps/website/public/robots.txt
Normal file
4
apps/website/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = computed(() => [
|
||||
{ icon: '📚', label: t('academy.tutorials', locale) },
|
||||
{ icon: '🎥', label: t('academy.videos', locale) },
|
||||
{ icon: '🛠️', label: t('academy.projects', locale) }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,14 +19,15 @@ const features = [
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
{{ t('academy.badge', locale) }}
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">
|
||||
{{ t('academy.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
{{ t('academy.body', locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
@@ -40,7 +47,7 @@ const features = [
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
{{ t('academy.cta', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards = computed(() => [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
title: t('cta.desktop.title', locale),
|
||||
description: t('cta.desktop.desc', locale),
|
||||
cta: t('cta.desktop.cta', locale),
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
title: t('cta.cloud.title', locale),
|
||||
description: t('cta.cloud.desc', locale),
|
||||
cta: t('cta.cloud.cta', locale),
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
title: t('cta.api.title', locale),
|
||||
description: t('cta.api.desc', locale),
|
||||
cta: t('cta.api.cta', locale),
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
{{ t('cta.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
title: t('getStarted.step1.title', locale),
|
||||
description: t('getStarted.step1.desc', locale)
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
title: t('getStarted.step2.title', locale),
|
||||
description: t('getStarted.step2.desc', locale)
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
title: t('getStarted.step3.title', locale),
|
||||
description: t('getStarted.step3.desc', locale)
|
||||
}
|
||||
]
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('getStarted.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
{{ t('getStarted.subheading', locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
@@ -55,7 +62,7 @@ const steps = [
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
{{ t('getStarted.cta', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctaButtons = computed(() => [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
label: t('hero.cta.getStarted', locale),
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
label: t('hero.cta.learnMore', locale),
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,12 +46,11 @@ const ctaButtons = [
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
{{ t('hero.headline', locale) }}
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
{{ t('hero.subheadline', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
@@ -7,13 +14,11 @@
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
{{ t('manifesto.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
{{ t('manifesto.body', locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = computed(() => [
|
||||
t('showcase.nodeEditor', locale),
|
||||
t('showcase.realTimePreview', locale),
|
||||
t('showcase.versionControl', locale)
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -8,9 +18,11 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('showcase.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
{{ t('showcase.subheading', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +40,9 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ t('showcase.placeholder', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,39 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
const columns = [
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { localePath, t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: 'Product',
|
||||
title: t('footer.product', locale),
|
||||
links: [
|
||||
{ label: 'Comfy Desktop', href: '/download' },
|
||||
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
|
||||
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
|
||||
{ label: 'Pricing', href: '/pricing' }
|
||||
{
|
||||
label: t('footer.comfyDesktop', locale),
|
||||
href: localePath('/download', locale)
|
||||
},
|
||||
{ label: t('footer.comfyCloud', locale), href: 'https://app.comfy.org' },
|
||||
{ label: t('footer.comfyHub', locale), href: 'https://hub.comfy.org' },
|
||||
{
|
||||
label: t('footer.pricing', locale),
|
||||
href: localePath('/pricing', locale)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Resources',
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: 'Documentation', href: 'https://docs.comfy.org' },
|
||||
{ label: 'Blog', href: 'https://blog.comfy.org' },
|
||||
{ label: 'Gallery', href: '/gallery' },
|
||||
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
|
||||
{
|
||||
label: t('footer.documentation', locale),
|
||||
href: 'https://docs.comfy.org'
|
||||
},
|
||||
{ label: t('footer.blog', locale), href: 'https://blog.comfy.org' },
|
||||
{
|
||||
label: t('footer.gallery', locale),
|
||||
href: localePath('/gallery', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.github', locale),
|
||||
href: 'https://github.com/comfyanonymous/ComfyUI'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
title: t('footer.company', locale),
|
||||
links: [
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
{ label: t('footer.about', locale), href: localePath('/about', locale) },
|
||||
{
|
||||
label: t('footer.careers', locale),
|
||||
href: localePath('/careers', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.enterprise', locale),
|
||||
href: localePath('/enterprise', locale)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Legal',
|
||||
title: t('footer.legal', locale),
|
||||
links: [
|
||||
{ label: 'Terms of Service', href: '/terms-of-service' },
|
||||
{ label: 'Privacy Policy', href: '/privacy-policy' }
|
||||
{
|
||||
label: t('footer.terms', locale),
|
||||
href: localePath('/terms-of-service', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.privacy', locale),
|
||||
href: localePath('/privacy-policy', locale)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const socials = [
|
||||
{
|
||||
@@ -76,11 +110,16 @@ const socials = [
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="lg:col-span-1">
|
||||
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<a
|
||||
:href="localePath('/', locale)"
|
||||
class="text-2xl font-bold text-brand-yellow italic"
|
||||
>
|
||||
Comfy
|
||||
</a>
|
||||
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||
<p class="mt-4 text-sm text-smoke-700">
|
||||
Professional control of visual AI.
|
||||
{{ t('footer.tagline', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +135,10 @@ const socials = [
|
||||
v-for="link in column.links"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:target="link.href.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="
|
||||
link.href.startsWith('http') ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
class="text-sm text-smoke-700 transition-colors hover:text-white"
|
||||
>
|
||||
{{ link.label }}
|
||||
@@ -109,7 +152,8 @@ const socials = [
|
||||
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
© {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
|
||||
© {{ new Date().getFullYear() }}
|
||||
{{ t('footer.copyright', locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Social icons -->
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { localePath, t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const currentPath = ref('')
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'ENTERPRISE', href: '/enterprise' },
|
||||
{ label: 'GALLERY', href: '/gallery' },
|
||||
{ label: 'ABOUT', href: '/about' },
|
||||
{ label: 'CAREERS', href: '/careers' }
|
||||
]
|
||||
const navLinks = computed(() => [
|
||||
{
|
||||
label: t('nav.enterprise', locale),
|
||||
href: localePath('/enterprise', locale)
|
||||
},
|
||||
{ label: t('nav.gallery', locale), href: localePath('/gallery', locale) },
|
||||
{ label: t('nav.about', locale), href: localePath('/about', locale) },
|
||||
{ label: t('nav.careers', locale), href: localePath('/careers', locale) }
|
||||
])
|
||||
|
||||
const ctaLinks = [
|
||||
{
|
||||
@@ -49,14 +57,19 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
aria-label="Main navigation"
|
||||
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
:aria-label="t('nav.ariaLabel', locale)"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<a
|
||||
:href="localePath('/', locale)"
|
||||
class="text-2xl font-bold text-brand-yellow italic"
|
||||
>
|
||||
Comfy
|
||||
</a>
|
||||
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div class="hidden items-center gap-8 md:flex">
|
||||
@@ -77,8 +90,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
@@ -90,7 +103,7 @@ onUnmounted(() => {
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
class="flex flex-col gap-1.5 md:hidden"
|
||||
aria-label="Toggle menu"
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
@@ -135,8 +148,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
@@ -14,11 +20,11 @@ const logos = [
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
const metrics = computed(() => [
|
||||
{ value: '60K+', label: t('social.customNodes', locale) },
|
||||
{ value: '106K+', label: t('social.githubStars', locale) },
|
||||
{ value: '500K+', label: t('social.communityMembers', locale) }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,7 +34,7 @@ const metrics = [
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
{{ t('social.heading', locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const industryKeys = [
|
||||
'All',
|
||||
'VFX',
|
||||
'Gaming',
|
||||
'Advertising',
|
||||
'Photography'
|
||||
] as const
|
||||
|
||||
const industryLabels = computed(() => ({
|
||||
All: t('testimonials.all', locale),
|
||||
VFX: t('testimonials.vfx', locale),
|
||||
Gaming: t('testimonials.gaming', locale),
|
||||
Advertising: t('testimonials.advertising', locale),
|
||||
Photography: t('testimonials.photography', locale)
|
||||
}))
|
||||
|
||||
const activeFilter = ref<(typeof industryKeys)[number]>('All')
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
@@ -12,7 +31,7 @@ const testimonials = [
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
industry: 'VFX' as const
|
||||
},
|
||||
{
|
||||
quote:
|
||||
@@ -20,7 +39,7 @@ const testimonials = [
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
industry: 'Gaming' as const
|
||||
},
|
||||
{
|
||||
quote:
|
||||
@@ -28,7 +47,7 @@ const testimonials = [
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
industry: 'Advertising' as const
|
||||
}
|
||||
]
|
||||
|
||||
@@ -42,13 +61,13 @@ const filteredTestimonials = computed(() => {
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
{{ t('testimonials.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
v-for="industry in industryKeys"
|
||||
:key="industry"
|
||||
type="button"
|
||||
:aria-pressed="activeFilter === industry"
|
||||
@@ -60,7 +79,7 @@ const filteredTestimonials = computed(() => {
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
{{ industryLabels[industry] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +104,7 @@ const filteredTestimonials = computed(() => {
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const categories = computed(() => [
|
||||
t('useCase.vfx', locale),
|
||||
t('useCase.agencies', locale),
|
||||
t('useCase.gaming', locale),
|
||||
t('useCase.ecommerce', locale),
|
||||
t('useCase.community', locale)
|
||||
])
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
@@ -27,7 +31,7 @@ const activeCategory = ref(0)
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
{{ t('useCase.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
@@ -52,15 +56,14 @@ const activeCategory = ref(0)
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
{{ t('useCase.body', locale) }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
{{ t('useCase.cta', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const pillars = computed(() => [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
title: t('pillars.buildTitle', locale),
|
||||
description: t('pillars.buildDesc', locale)
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
title: t('pillars.customizeTitle', locale),
|
||||
description: t('pillars.customizeDesc', locale)
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
title: t('pillars.refineTitle', locale),
|
||||
description: t('pillars.refineDesc', locale)
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
title: t('pillars.automateTitle', locale),
|
||||
description: t('pillars.automateDesc', locale)
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
title: t('pillars.runTitle', locale),
|
||||
description: t('pillars.runDesc', locale)
|
||||
}
|
||||
]
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,10 +39,10 @@ const pillars = [
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
{{ t('pillars.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
{{ t('pillars.subheading', locale) }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
253
apps/website/src/i18n/translations.ts
Normal file
253
apps/website/src/i18n/translations.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const translations = {
|
||||
// HeroSection
|
||||
'hero.headline': {
|
||||
en: 'Professional Control of Visual AI',
|
||||
'zh-CN': '视觉 AI 的专业控制'
|
||||
},
|
||||
'hero.subheadline': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
'hero.cta.getStarted': { en: 'GET STARTED', 'zh-CN': '立即开始' },
|
||||
'hero.cta.learnMore': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
|
||||
// SocialProofBar
|
||||
'social.heading': {
|
||||
en: 'Trusted by Industry Leaders',
|
||||
'zh-CN': '受到行业领导者的信赖'
|
||||
},
|
||||
'social.customNodes': { en: 'Custom Nodes', 'zh-CN': '自定义节点' },
|
||||
'social.githubStars': { en: 'GitHub Stars', 'zh-CN': 'GitHub 星标' },
|
||||
'social.communityMembers': {
|
||||
en: 'Community Members',
|
||||
'zh-CN': '社区成员'
|
||||
},
|
||||
|
||||
// ProductShowcase
|
||||
'showcase.heading': { en: 'See Comfy in Action', 'zh-CN': '观看 Comfy 实战' },
|
||||
'showcase.subheading': {
|
||||
en: 'Watch how professionals build AI workflows with unprecedented control',
|
||||
'zh-CN': '观看专业人士如何以前所未有的控制力构建 AI 工作流'
|
||||
},
|
||||
'showcase.placeholder': {
|
||||
en: 'Workflow Demo Coming Soon',
|
||||
'zh-CN': '工作流演示即将推出'
|
||||
},
|
||||
'showcase.nodeEditor': { en: 'Node-Based Editor', 'zh-CN': '节点编辑器' },
|
||||
'showcase.realTimePreview': {
|
||||
en: 'Real-Time Preview',
|
||||
'zh-CN': '实时预览'
|
||||
},
|
||||
'showcase.versionControl': {
|
||||
en: 'Version Control',
|
||||
'zh-CN': '版本控制'
|
||||
},
|
||||
|
||||
// ValuePillars
|
||||
'pillars.heading': {
|
||||
en: 'The Building Blocks of AI Production',
|
||||
'zh-CN': 'AI 制作的基本要素'
|
||||
},
|
||||
'pillars.subheading': {
|
||||
en: 'Five powerful capabilities that give you complete control',
|
||||
'zh-CN': '五大强大功能,让您完全掌控'
|
||||
},
|
||||
'pillars.buildTitle': { en: 'Build', 'zh-CN': '构建' },
|
||||
'pillars.buildDesc': {
|
||||
en: 'Design complex AI workflows visually with our node-based editor',
|
||||
'zh-CN': '使用节点编辑器直观地设计复杂的 AI 工作流'
|
||||
},
|
||||
'pillars.customizeTitle': { en: 'Customize', 'zh-CN': '自定义' },
|
||||
'pillars.customizeDesc': {
|
||||
en: 'Fine-tune every parameter across any model architecture',
|
||||
'zh-CN': '在任何模型架构中微调每个参数'
|
||||
},
|
||||
'pillars.refineTitle': { en: 'Refine', 'zh-CN': '优化' },
|
||||
'pillars.refineDesc': {
|
||||
en: 'Iterate on outputs with precision controls and real-time preview',
|
||||
'zh-CN': '通过精确控制和实时预览迭代输出'
|
||||
},
|
||||
'pillars.automateTitle': { en: 'Automate', 'zh-CN': '自动化' },
|
||||
'pillars.automateDesc': {
|
||||
en: 'Scale your workflows with batch processing and API integration',
|
||||
'zh-CN': '通过批处理和 API 集成扩展工作流'
|
||||
},
|
||||
'pillars.runTitle': { en: 'Run', 'zh-CN': '运行' },
|
||||
'pillars.runDesc': {
|
||||
en: 'Deploy locally or in the cloud with identical results',
|
||||
'zh-CN': '在本地或云端部署,获得相同的结果'
|
||||
},
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.heading': {
|
||||
en: 'Built for Every Creative Industry',
|
||||
'zh-CN': '为每个创意行业而生'
|
||||
},
|
||||
'useCase.vfx': { en: 'VFX & Animation', 'zh-CN': '视觉特效与动画' },
|
||||
'useCase.agencies': { en: 'Creative Agencies', 'zh-CN': '创意机构' },
|
||||
'useCase.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
|
||||
'useCase.ecommerce': {
|
||||
en: 'eCommerce & Fashion',
|
||||
'zh-CN': '电商与时尚'
|
||||
},
|
||||
'useCase.community': {
|
||||
en: 'Community & Hobbyists',
|
||||
'zh-CN': '社区与爱好者'
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows, and a community that builds faster than any one company could.',
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': { en: 'EXPLORE WORKFLOWS', 'zh-CN': '探索工作流' },
|
||||
|
||||
// CaseStudySpotlight
|
||||
'caseStudy.heading': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'caseStudy.subheading': {
|
||||
en: 'See how leading studios use Comfy in production',
|
||||
'zh-CN': '了解领先工作室如何在生产中使用 Comfy'
|
||||
},
|
||||
'caseStudy.readMore': { en: 'READ CASE STUDY', 'zh-CN': '阅读案例' },
|
||||
|
||||
// TestimonialsSection
|
||||
'testimonials.heading': {
|
||||
en: 'What Professionals Say',
|
||||
'zh-CN': '专业人士的评价'
|
||||
},
|
||||
'testimonials.all': { en: 'All', 'zh-CN': '全部' },
|
||||
'testimonials.vfx': { en: 'VFX', 'zh-CN': '特效' },
|
||||
'testimonials.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
|
||||
'testimonials.advertising': { en: 'Advertising', 'zh-CN': '广告' },
|
||||
'testimonials.photography': { en: 'Photography', 'zh-CN': '摄影' },
|
||||
|
||||
// GetStartedSection
|
||||
'getStarted.heading': {
|
||||
en: 'Get Started in Minutes',
|
||||
'zh-CN': '几分钟即可开始'
|
||||
},
|
||||
'getStarted.subheading': {
|
||||
en: 'From download to your first AI-generated output in three simple steps',
|
||||
'zh-CN': '从下载到首次 AI 生成输出,只需三个简单步骤'
|
||||
},
|
||||
'getStarted.step1.title': {
|
||||
en: 'Download & Sign Up',
|
||||
'zh-CN': '下载与注册'
|
||||
},
|
||||
'getStarted.step1.desc': {
|
||||
en: 'Get Comfy Desktop for free or create a Cloud account',
|
||||
'zh-CN': '免费获取 Comfy Desktop 或创建云端账号'
|
||||
},
|
||||
'getStarted.step2.title': {
|
||||
en: 'Load a Workflow',
|
||||
'zh-CN': '加载工作流'
|
||||
},
|
||||
'getStarted.step2.desc': {
|
||||
en: 'Choose from thousands of community workflows or build your own',
|
||||
'zh-CN': '从数千个社区工作流中选择,或自行构建'
|
||||
},
|
||||
'getStarted.step3.title': { en: 'Generate', 'zh-CN': '生成' },
|
||||
'getStarted.step3.desc': {
|
||||
en: 'Hit run and watch your AI workflow come to life',
|
||||
'zh-CN': '点击运行,观看 AI 工作流生动呈现'
|
||||
},
|
||||
'getStarted.cta': { en: 'DOWNLOAD COMFY', 'zh-CN': '下载 COMFY' },
|
||||
|
||||
// CTASection
|
||||
'cta.heading': {
|
||||
en: 'Choose Your Way to Comfy',
|
||||
'zh-CN': '选择您的 Comfy 方式'
|
||||
},
|
||||
'cta.desktop.title': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
|
||||
'cta.desktop.desc': {
|
||||
en: 'Full power on your local machine. Free and open source.',
|
||||
'zh-CN': '在本地机器上释放全部性能。免费开源。'
|
||||
},
|
||||
'cta.desktop.cta': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
'cta.cloud.title': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'cta.cloud.desc': {
|
||||
en: 'Run workflows in the cloud. No GPU required.',
|
||||
'zh-CN': '在云端运行工作流,无需 GPU。'
|
||||
},
|
||||
'cta.cloud.cta': { en: 'TRY CLOUD', 'zh-CN': '试用云端' },
|
||||
'cta.api.title': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
'cta.api.desc': {
|
||||
en: 'Integrate AI generation into your applications.',
|
||||
'zh-CN': '将 AI 生成功能集成到您的应用程序中。'
|
||||
},
|
||||
'cta.api.cta': { en: 'VIEW DOCS', 'zh-CN': '查看文档' },
|
||||
|
||||
// ManifestoSection
|
||||
'manifesto.heading': { en: 'Method, Not Magic', 'zh-CN': '方法,而非魔法' },
|
||||
'manifesto.body': {
|
||||
en: 'We believe in giving creators real control over AI. Not black boxes. Not magic buttons. But transparent, reproducible, node-by-node control over every step of the creative process.',
|
||||
'zh-CN':
|
||||
'我们相信应赋予创作者对 AI 的真正控制权。没有黑箱,没有魔法按钮,而是对创作过程每一步的透明、可复现、逐节点控制。'
|
||||
},
|
||||
|
||||
// AcademySection
|
||||
'academy.badge': { en: 'COMFY ACADEMY', 'zh-CN': 'COMFY 学院' },
|
||||
'academy.heading': {
|
||||
en: 'Master AI Workflows',
|
||||
'zh-CN': '掌握 AI 工作流'
|
||||
},
|
||||
'academy.body': {
|
||||
en: 'Learn to build professional AI workflows with guided tutorials, video courses, and hands-on projects.',
|
||||
'zh-CN': '通过指导教程、视频课程和实践项目,学习构建专业的 AI 工作流。'
|
||||
},
|
||||
'academy.tutorials': { en: 'Guided Tutorials', 'zh-CN': '指导教程' },
|
||||
'academy.videos': { en: 'Video Courses', 'zh-CN': '视频课程' },
|
||||
'academy.projects': { en: 'Hands-on Projects', 'zh-CN': '实践项目' },
|
||||
'academy.cta': { en: 'EXPLORE ACADEMY', 'zh-CN': '探索学院' },
|
||||
|
||||
// SiteNav
|
||||
'nav.ariaLabel': { en: 'Main navigation', 'zh-CN': '主导航' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.enterprise': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'nav.gallery': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'nav.about': { en: 'ABOUT', 'zh-CN': '关于' },
|
||||
'nav.careers': { en: 'CAREERS', 'zh-CN': '招聘' },
|
||||
'nav.cloud': { en: 'COMFY CLOUD', 'zh-CN': 'COMFY 云端' },
|
||||
'nav.hub': { en: 'COMFY HUB', 'zh-CN': 'COMFY HUB' },
|
||||
|
||||
// SiteFooter
|
||||
'footer.tagline': {
|
||||
en: 'Professional control of visual AI.',
|
||||
'zh-CN': '视觉 AI 的专业控制。'
|
||||
},
|
||||
'footer.product': { en: 'Product', 'zh-CN': '产品' },
|
||||
'footer.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
'footer.company': { en: 'Company', 'zh-CN': '公司' },
|
||||
'footer.legal': { en: 'Legal', 'zh-CN': '法律' },
|
||||
'footer.copyright': {
|
||||
en: 'Comfy Org. All rights reserved.',
|
||||
'zh-CN': 'Comfy Org. 保留所有权利。'
|
||||
},
|
||||
'footer.comfyDesktop': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
|
||||
'footer.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'footer.comfyHub': { en: 'ComfyHub', 'zh-CN': 'ComfyHub' },
|
||||
'footer.pricing': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
'footer.documentation': { en: 'Documentation', 'zh-CN': '文档' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'footer.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'footer.about': { en: 'About', 'zh-CN': '关于' },
|
||||
'footer.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'footer.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
|
||||
'footer.terms': { en: 'Terms of Service', 'zh-CN': '服务条款' },
|
||||
'footer.privacy': { en: 'Privacy Policy', 'zh-CN': '隐私政策' }
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
|
||||
export function localePath(path: string, locale: Locale): string {
|
||||
return locale === 'en' ? path : `/${locale}${path}`
|
||||
}
|
||||
|
||||
export type { Locale }
|
||||
@@ -7,12 +7,14 @@ interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
ogImage = '/og-default.png',
|
||||
noindex = false,
|
||||
} = Astro.props
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
@@ -21,6 +23,29 @@ const ogImageURL = new URL(ogImage, siteBase)
|
||||
const locale = Astro.currentLocale ?? 'en'
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/favicon.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -29,26 +54,36 @@ const gtmEnabled = import.meta.env.PROD
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImageURL.href} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:url" content={canonicalURL.href} />
|
||||
<meta property="og:locale" content={locale} />
|
||||
<meta property="og:site_name" content="Comfy" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@comaboratory" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
|
||||
<script type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
<script is:inline define:vars={{ gtmId }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import SiteFooter from '../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="Privacy Policy — Comfy"
|
||||
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24">
|
||||
|
||||
@@ -7,6 +7,7 @@ import SiteFooter from '../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="Terms of Service — Comfy"
|
||||
description="Terms of Service for ComfyUI and related Comfy services."
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
|
||||
|
||||
@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
|
||||
const team = [
|
||||
{ name: 'comfyanonymous', role: 'Creator of ComfyUI, cofounder' },
|
||||
{ name: 'Dr.Lt.Data', role: 'Creator of ComfyUI-Manager and Impact/Inspire Pack' },
|
||||
{ name: 'pythongosssss', role: 'Major contributor, creator of ComfyUI-Custom-Scripts' },
|
||||
{ name: 'yoland68', role: 'Creator of ComfyCLI, cofounder, ex-Google' },
|
||||
{ name: 'robinjhuang', role: 'Maintains Comfy Registry, cofounder, ex-Google Cloud' },
|
||||
{ name: 'jojodecay', role: 'ComfyUI event series host, community & partnerships' },
|
||||
{ name: 'christian-byrne', role: 'Fullstack developer' },
|
||||
{ name: 'Kosinkadink', role: 'Creator of AnimateDiff-Evolved and Advanced-ControlNet' },
|
||||
{ name: 'webfiltered', role: 'Overhauled Litegraph library' },
|
||||
{ name: 'Pablo', role: 'Product Design, ex-AI startup founder' },
|
||||
{ name: 'ComfyUI Wiki (Daxiong)', role: 'Official docs and templates' },
|
||||
{ name: 'ctrlbenlu (Ben)', role: 'Software engineer, ex-robotics' },
|
||||
{ name: 'Purz Beats', role: 'Motion graphics designer and ML Engineer' },
|
||||
{ name: 'Ricyu (Rich)', role: 'Software engineer, ex-Meta' },
|
||||
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
|
||||
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager 和 Impact/Inspire Pack 作者' },
|
||||
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
|
||||
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
|
||||
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
|
||||
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
|
||||
{ name: 'christian-byrne', role: '全栈开发工程师' },
|
||||
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved 和 Advanced-ControlNet 作者' },
|
||||
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
|
||||
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
|
||||
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
|
||||
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
|
||||
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
|
||||
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
|
||||
]
|
||||
|
||||
const collaborators = [
|
||||
{ name: 'Yogo', role: 'Collaborator' },
|
||||
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
|
||||
{ name: 'Julien (MJM)', role: 'Collaborator' },
|
||||
{ name: 'Yogo', role: '协作者' },
|
||||
{ name: 'Fill (Machine Delusions)', role: '协作者' },
|
||||
{ name: 'Julien (MJM)', role: '协作者' },
|
||||
]
|
||||
|
||||
const projects = [
|
||||
{ name: 'ComfyUI', description: 'The core node-based interface for generative AI workflows.' },
|
||||
{ name: 'ComfyUI Manager', description: 'Install, update, and manage custom nodes with one click.' },
|
||||
{ name: 'Comfy Registry', description: 'The official registry for publishing and discovering custom nodes.' },
|
||||
{ name: 'Frontends', description: 'The desktop and web frontends that power the ComfyUI experience.' },
|
||||
{ name: 'Docs', description: 'Official documentation, guides, and tutorials.' },
|
||||
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
|
||||
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
|
||||
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
|
||||
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
|
||||
{ name: 'Docs', description: '官方文档、指南和教程。' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'Is ComfyUI free?',
|
||||
a: 'Yes. ComfyUI is free and open-source under the GPL-3.0 license. You can use it for personal and commercial projects.',
|
||||
q: 'ComfyUI 免费吗?',
|
||||
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
|
||||
},
|
||||
{
|
||||
q: 'Who is behind ComfyUI?',
|
||||
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
|
||||
q: '谁在开发 ComfyUI?',
|
||||
a: 'ComfyUI 由 comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
|
||||
},
|
||||
{
|
||||
q: 'How can I contribute?',
|
||||
a: 'Check out our GitHub repositories to report issues, submit pull requests, or build custom nodes. Join our Discord community to connect with other contributors.',
|
||||
q: '如何参与贡献?',
|
||||
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
|
||||
},
|
||||
{
|
||||
q: 'What are the future plans?',
|
||||
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
|
||||
q: '未来有什么计划?',
|
||||
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
|
||||
<SiteNav client:load />
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main>
|
||||
<!-- Hero -->
|
||||
<!-- 主页横幅 -->
|
||||
<section class="px-6 pb-24 pt-40 text-center">
|
||||
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
|
||||
Crafting the next frontier of visual and audio media
|
||||
开创视觉与音频媒体的下一个前沿
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
|
||||
An open-source community and company building the most powerful tools for generative AI creators.
|
||||
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Our Mission -->
|
||||
<!-- 我们的使命 -->
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
|
||||
<p class="mt-6 text-3xl font-bold md:text-4xl">
|
||||
We want to build the operating system for Gen AI.
|
||||
我们想打造生成式 AI 的操作系统。
|
||||
</p>
|
||||
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
|
||||
We're building the foundational tools that give creators full control over generative AI.
|
||||
From image and video synthesis to audio generation, ComfyUI provides a modular,
|
||||
node-based environment where professionals and enthusiasts can craft, iterate,
|
||||
and deploy production-quality workflows — without black boxes.
|
||||
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
|
||||
从图像和视频合成到音频生成,ComfyUI 提供了一个模块化的
|
||||
节点式环境,让专业人士和爱好者可以创建、迭代
|
||||
和部署生产级工作流——没有黑箱。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What Do We Do? -->
|
||||
<!-- 我们做什么? -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">What Do We Do?</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
|
||||
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
|
||||
@@ -98,24 +98,23 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Who We Are -->
|
||||
<!-- 我们是谁 -->
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-3xl font-bold md:text-4xl">Who We Are</h2>
|
||||
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
|
||||
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
|
||||
ComfyUI started as a personal project by comfyanonymous and grew into a global community
|
||||
of creators, developers, and researchers. Today, Comfy Org is a small, flat team based in
|
||||
San Francisco, backed by investors who believe in open-source AI tooling. We work
|
||||
alongside an incredible community of contributors who build custom nodes, share workflows,
|
||||
and push the boundaries of what's possible with generative AI.
|
||||
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
|
||||
创作者、开发者和研究者社区。今天,Comfy Org 是一个位于旧金山的小型扁平化团队,
|
||||
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
|
||||
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Team -->
|
||||
<!-- 团队 -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">Team</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
|
||||
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{team.map((member) => (
|
||||
<div class="rounded-xl border border-white/10 p-5 text-center">
|
||||
@@ -128,10 +127,10 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Collaborators -->
|
||||
<!-- 协作者 -->
|
||||
<section class="bg-charcoal-800 px-6 py-16">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="text-2xl font-bold">Collaborators</h2>
|
||||
<h2 class="text-2xl font-bold">协作者</h2>
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
{collaborators.map((person) => (
|
||||
<div class="text-center">
|
||||
@@ -143,10 +142,10 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQs -->
|
||||
<!-- 常见问题 -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">FAQs</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
|
||||
<div class="mt-12 space-y-10">
|
||||
{faqs.map((faq) => (
|
||||
<div>
|
||||
@@ -158,19 +157,19 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Join Our Team CTA -->
|
||||
<!-- 加入我们 CTA -->
|
||||
<section class="bg-charcoal-800 px-6 py-24 text-center">
|
||||
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
|
||||
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
|
||||
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
|
||||
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
|
||||
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
|
||||
</p>
|
||||
<a
|
||||
href="/careers"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
View Open Positions
|
||||
查看开放职位
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -78,7 +78,7 @@ const questions = [
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main>
|
||||
<!-- Hero -->
|
||||
<section class="px-6 pb-24 pt-40">
|
||||
@@ -196,5 +196,5 @@ const questions = [
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -32,7 +32,7 @@ const cards = [
|
||||
---
|
||||
|
||||
<BaseLayout title="下载 — Comfy">
|
||||
<SiteNav client:load />
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
|
||||
<h1 class="text-4xl font-bold text-white md:text-5xl">
|
||||
下载 ComfyUI
|
||||
@@ -76,5 +76,5 @@ const cards = [
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="作品集 — Comfy">
|
||||
<SiteNav client:load />
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main class="bg-black text-white">
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
|
||||
@@ -39,5 +39,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
<HeroSection locale="zh-CN" />
|
||||
<SocialProofBar locale="zh-CN" />
|
||||
<ProductShowcase locale="zh-CN" />
|
||||
<ValuePillars locale="zh-CN" />
|
||||
<UseCaseSection locale="zh-CN" client:visible />
|
||||
<CaseStudySpotlight locale="zh-CN" />
|
||||
<TestimonialsSection locale="zh-CN" client:visible />
|
||||
<GetStartedSection locale="zh-CN" />
|
||||
<CTASection locale="zh-CN" />
|
||||
<ManifestoSection locale="zh-CN" />
|
||||
<AcademySection locale="zh-CN" />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,8 +7,9 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="隐私政策 — Comfy"
|
||||
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24">
|
||||
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
|
||||
<p class="mt-2 text-sm text-smoke-500">生效日期:2025年4月18日</p>
|
||||
@@ -228,5 +229,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,8 +7,9 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="服务条款 — Comfy"
|
||||
description="ComfyUI 及相关 Comfy 服务的服务条款。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
|
||||
<header class="mb-16">
|
||||
<h1 class="text-3xl font-bold text-white">服务条款</h1>
|
||||
@@ -215,5 +216,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
26
apps/website/vercel.json
Normal file
26
apps/website/vercel.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "pnpm --filter @comfyorg/website build",
|
||||
"outputDirectory": "apps/website/dist",
|
||||
"installCommand": "pnpm install --frozen-lockfile",
|
||||
"framework": null,
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
"destination": "/cloud/pricing",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/enterprise",
|
||||
"destination": "/cloud/enterprise",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/blog",
|
||||
"destination": "https://blog.comfy.org/",
|
||||
"permanent": true
|
||||
},
|
||||
{ "source": "/contact", "destination": "/about", "permanent": true },
|
||||
{ "source": "/press", "destination": "/about", "permanent": true }
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
|
||||
See `@browser_tests/FLAKE_PREVENTION_RULES.md` when triaging or editing
|
||||
flaky browser tests.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
||||
114
browser_tests/FLAKE_PREVENTION_RULES.md
Normal file
114
browser_tests/FLAKE_PREVENTION_RULES.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Browser Test Flake Prevention Rules
|
||||
|
||||
Reference this file as `@browser_tests/FLAKE_PREVENTION_RULES.md` when
|
||||
debugging or updating flaky Playwright tests.
|
||||
|
||||
These rules are distilled from the PR 10817 stabilization thread chain. They
|
||||
exist to make flaky-test triage faster and more repeatable.
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
Before merging a flaky-test fix, confirm all of these are true:
|
||||
|
||||
- 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
|
||||
- the fix stays local unless a shared helper truly owns the race
|
||||
- local verification uses a targeted rerun
|
||||
|
||||
## 1. Start With CI Evidence
|
||||
|
||||
- Do not trust the top-level GitHub check result alone.
|
||||
- Inspect the latest Playwright `report.json` directly, even on a green run.
|
||||
- Treat tests marked `flaky` in `report.json` as real work.
|
||||
- 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. Wait For The Real Readiness Boundary
|
||||
|
||||
- Visible is not always ready.
|
||||
- If the behavior depends on internal state, wait on that state.
|
||||
- After canvas interactions, call `await comfyPage.nextFrame()` unless the
|
||||
helper already guarantees a settled frame.
|
||||
- After workflow reloads or node-definition refreshes, wait for the reload to
|
||||
finish before continuing.
|
||||
|
||||
Common readiness boundaries:
|
||||
|
||||
- `node.imgs` populated before opening image context menus
|
||||
- settings cleanup finished before asserting persisted state
|
||||
- locale-triggered workflow reload finished before selecting nodes
|
||||
- real builder UI ready, not transient helper metadata
|
||||
|
||||
## 3. Choose The Smallest Correct Assertion
|
||||
|
||||
- Use built-in retrying locator assertions when locator state is the behavior.
|
||||
- Use `expect.poll()` for a single async value.
|
||||
- Use `expect(async () => { ... }).toPass()` only when multiple assertions must
|
||||
settle together.
|
||||
- Do not make immediate assertions after async UI mutations, settings writes,
|
||||
clipboard writes, or graph updates.
|
||||
- Never use `waitForTimeout()` to hide a race.
|
||||
|
||||
```ts
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2'))
|
||||
.toEqual([])
|
||||
```
|
||||
|
||||
## 4. Prefer Behavioral Assertions
|
||||
|
||||
- Use screenshots only when appearance is the behavior under test.
|
||||
- If a screenshot only indirectly proves behavior, replace it with a direct
|
||||
assertion.
|
||||
- Prefer assertions on link counts, positions, visible menu items, persisted
|
||||
settings, and node state.
|
||||
|
||||
## 5. Keep Helper 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.
|
||||
|
||||
## 6. Verify Narrowly
|
||||
|
||||
- Prefer targeted reruns through `pnpm test:browser:local`.
|
||||
- On Windows, prefer `file:line` or whole-spec arguments over `--grep` when the
|
||||
wrapper has quoting issues.
|
||||
- Use `--repeat-each 5` for targeted flake verification unless the failure needs
|
||||
a different reproduction pattern.
|
||||
- Verify with the smallest command that exercises the flaky path.
|
||||
|
||||
## 7. Common Flake Patterns
|
||||
|
||||
| Pattern | Bad | Fix |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width).toBe(w)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width)).toBe(w)` |
|
||||
| **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||
| **Fake readiness helper** | Helper that clicks but doesn't assert state | Remove; poll the actual value |
|
||||
| **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||
| **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000ms; prefer default (5000ms) |
|
||||
| **Immediate count()** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||
| **Immediate evaluate after mutation** | `setSetting(); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||
| **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||
|
||||
## Current Local Noise
|
||||
|
||||
These are local distractions, not automatic 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 for handling local noise:
|
||||
|
||||
- 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
|
||||
@@ -210,8 +210,8 @@ Most common testing needs are already addressed by these helpers, which will mak
|
||||
|
||||
```typescript
|
||||
// Prefer this:
|
||||
expect(await node.isPinned()).toBe(true)
|
||||
expect(await node.getProperty('title')).toBe('Expected Title')
|
||||
await expect.poll(() => node.isPinned()).toBe(true)
|
||||
await expect.poll(() => node.getProperty('title')).toBe('Expected Title')
|
||||
|
||||
// Over this - only use when needed:
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
|
||||
|
||||
47
browser_tests/assets/3d/load3d_node.json
Normal file
47
browser_tests/assets/3d/load3d_node.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Load3D",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 650],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MESH",
|
||||
"type": "MESH",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Load3D"
|
||||
},
|
||||
"widgets_values": ["", 1024, 1024, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
40
browser_tests/assets/cube.obj
Normal file
40
browser_tests/assets/cube.obj
Normal file
@@ -0,0 +1,40 @@
|
||||
# Blender 5.2.0 Alpha
|
||||
# www.blender.org
|
||||
mtllib Untitled.mtl
|
||||
o Cube
|
||||
v 2.857396 2.486626 -0.081892
|
||||
v 2.857396 0.486626 -0.081892
|
||||
v 2.857396 2.486626 1.918108
|
||||
v 2.857396 0.486626 1.918108
|
||||
v 0.857396 2.486626 -0.081892
|
||||
v 0.857396 0.486626 -0.081892
|
||||
v 0.857396 2.486626 1.918108
|
||||
v 0.857396 0.486626 1.918108
|
||||
vn -0.0000 1.0000 -0.0000
|
||||
vn -0.0000 -0.0000 1.0000
|
||||
vn -1.0000 -0.0000 -0.0000
|
||||
vn -0.0000 -1.0000 -0.0000
|
||||
vn 1.0000 -0.0000 -0.0000
|
||||
vn -0.0000 -0.0000 -1.0000
|
||||
vt 0.625000 0.500000
|
||||
vt 0.875000 0.500000
|
||||
vt 0.875000 0.750000
|
||||
vt 0.625000 0.750000
|
||||
vt 0.375000 0.750000
|
||||
vt 0.625000 1.000000
|
||||
vt 0.375000 1.000000
|
||||
vt 0.375000 0.000000
|
||||
vt 0.625000 0.000000
|
||||
vt 0.625000 0.250000
|
||||
vt 0.375000 0.250000
|
||||
vt 0.125000 0.500000
|
||||
vt 0.375000 0.500000
|
||||
vt 0.125000 0.750000
|
||||
s 0
|
||||
usemtl Material
|
||||
f 1/1/1 5/2/1 7/3/1 3/4/1
|
||||
f 4/5/2 3/4/2 7/6/2 8/7/2
|
||||
f 8/8/3 7/9/3 5/10/3 6/11/3
|
||||
f 6/12/4 2/13/4 4/5/4 8/14/4
|
||||
f 2/13/5 1/1/5 3/4/5 4/5/5
|
||||
f 6/11/6 5/10/6 1/1/6 2/13/6
|
||||
39
browser_tests/assets/inputs/dynamic_combo.json
Normal file
39
browser_tests/assets/inputs/dynamic_combo.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ResizeImageMaskNode",
|
||||
"pos": [100, 100],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "result", "type": "IMAGE", "links": null }],
|
||||
"properties": {
|
||||
"Node name for S&R": "ResizeImageMaskNode"
|
||||
},
|
||||
"widgets_values": ["scale dimensions", 512, 512, "center", "area"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "SaveImage",
|
||||
"pos": [500, 100],
|
||||
"size": [210, 58],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": null }],
|
||||
"properties": {}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
85
browser_tests/assets/missing/missing_models_with_nodes.json
Normal file
85
browser_tests/assets/missing/missing_models_with_nodes.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
72
browser_tests/assets/missing/missing_nodes_and_media.json
Normal file
72
browser_tests/assets/missing/missing_nodes_and_media.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "UNKNOWN NODE",
|
||||
"pos": [48, 86],
|
||||
"size": [358, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [],
|
||||
"slot_index": 0,
|
||||
"shape": 6
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UNKNOWN NODE"
|
||||
},
|
||||
"widgets_values": ["wd-v1-4-moat-tagger-v2", 0.35, 0.85, false, false, ""]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 86],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
197
browser_tests/assets/subgraphs/basic-subgraph-zero-uuid.json
Normal file
197
browser_tests/assets/subgraphs/basic-subgraph-zero-uuid.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [144.15234375, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Mouse } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from './ComfyPage'
|
||||
import type { Position } from './types'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
/**
|
||||
* Used for drag and drop ops
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
@@ -43,7 +46,7 @@ import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
|
||||
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
@@ -177,7 +180,9 @@ export class ComfyPage {
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -227,7 +232,9 @@ export class ComfyPage {
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
@@ -385,9 +392,8 @@ export class ComfyPage {
|
||||
await modal.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/** Get number of DOM widgets on the canvas. */
|
||||
async getDOMWidgetCount() {
|
||||
return await this.page.locator('.dom-widget').count()
|
||||
get domWidgets(): Locator {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
async setFocusMode(focusMode: boolean) {
|
||||
@@ -440,6 +446,10 @@ export const comfyPageFixture = base.extend<{
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (testInfo.tags.includes('@cloud')) {
|
||||
await comfyPage.cloudAuth.mockAuth()
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const needsPerf =
|
||||
@@ -448,6 +458,7 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { VueNodeFixture } from './utils/vueNodeFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
@@ -48,13 +48,6 @@ export class VueNodeHelpers {
|
||||
return await this.nodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of selected Vue nodes
|
||||
*/
|
||||
async getSelectedNodeCount(): Promise<number> {
|
||||
return await this.selectedNodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Vue node IDs currently in the DOM
|
||||
*/
|
||||
@@ -109,6 +102,14 @@ export class VueNodeHelpers {
|
||||
await this.page.keyboard.press('Delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node by ID and delete it.
|
||||
*/
|
||||
async deleteNode(nodeId: string): Promise<void> {
|
||||
await this.selectNode(nodeId)
|
||||
await this.deleteSelected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Backspace key
|
||||
*/
|
||||
@@ -158,6 +159,21 @@ export class VueNodeHelpers {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a combo widget on a node.
|
||||
*/
|
||||
async selectComboOption(
|
||||
nodeTitle: string,
|
||||
widgetName: string,
|
||||
optionName: string
|
||||
): Promise<void> {
|
||||
const node = this.getNodeByTitle(nodeTitle)
|
||||
await node.getByRole('combobox', { name: widgetName, exact: true }).click()
|
||||
await this.page
|
||||
.getByRole('option', { name: optionName, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controls for input number widgets (increment/decrement buttons and input)
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,7 @@ export class BaseDialog {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.closeButton.click({ force: true })
|
||||
await this.closeButton.click()
|
||||
await this.waitForHidden()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
constructor(public readonly page: Page) {}
|
||||
readonly root: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
return this.root
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Add node filter condition' })
|
||||
}
|
||||
@@ -60,17 +64,13 @@ export class ComfyNodeSearchBox {
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
await this.input.fill(nodeName)
|
||||
await this.dropdown.waitFor({ state: 'visible' })
|
||||
if (options?.exact) {
|
||||
await this.dropdown
|
||||
.locator(`li[aria-label="${nodeName}"]`)
|
||||
.first()
|
||||
.click()
|
||||
} else {
|
||||
await this.dropdown
|
||||
.locator('li')
|
||||
.nth(options?.suggestionIndex || 0)
|
||||
.click()
|
||||
}
|
||||
|
||||
const nodeOption = options?.exact
|
||||
? this.dropdown.locator(`li[aria-label="${nodeName}"]`).first()
|
||||
: this.dropdown.locator('li').nth(options?.suggestionIndex ?? 0)
|
||||
|
||||
await expect(nodeOption).toBeVisible()
|
||||
await nodeOption.click()
|
||||
}
|
||||
|
||||
async addFilter(filterValue: string, filterType: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
type KeysOfType<T, Match> = {
|
||||
[K in keyof T]: T[K] extends Match ? K : never
|
||||
|
||||
@@ -20,6 +20,10 @@ export class ContextMenu {
|
||||
await this.page.getByRole('menuitem', { name }).click()
|
||||
}
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
async clickLitegraphMenuItem(name: string): Promise<void> {
|
||||
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
|
||||
}
|
||||
@@ -48,22 +52,22 @@ export class ContextMenu {
|
||||
return this
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
|
||||
console.warn(
|
||||
`[waitForHidden] ${menuName} waitFor failed:`,
|
||||
error.message
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Select a Vue node by clicking its header, then right-click to open
|
||||
* the context menu. Vue nodes require a selection click before the
|
||||
* right-click so the correct per-node menu items appear.
|
||||
*/
|
||||
async openForVueNode(header: Locator): Promise<this> {
|
||||
await header.click()
|
||||
await header.click({ button: 'right' })
|
||||
await this.primeVueMenu.waitFor({ state: 'visible' })
|
||||
return this
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
await Promise.all([
|
||||
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
|
||||
waitIfExists(this.litegraphMenu, 'litegraphMenu')
|
||||
this.primeVueMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class SettingDialog extends BaseDialog {
|
||||
constructor(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
@@ -168,10 +168,14 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
get activeWorkflowLabel(): Locator {
|
||||
return this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getActiveWorkflowName() {
|
||||
return await this.root
|
||||
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
|
||||
.innerText()
|
||||
return await this.activeWorkflowLabel.innerText()
|
||||
}
|
||||
|
||||
async getTopLevelSavedWorkflowNames() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class SignInDialog extends BaseDialog {
|
||||
readonly emailInput: Locator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Position } from './types'
|
||||
import type { Position } from '@e2e/fixtures/constants/types'
|
||||
|
||||
/**
|
||||
* Hardcoded positions for the default graph loaded in tests.
|
||||
|
||||
306
browser_tests/fixtures/data/assetFixtures.ts
Normal file
306
browser_tests/fixtures/data/assetFixtures.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2025-01-15T10:00:00Z',
|
||||
updated_at: '2025-01-15T10:00:00Z',
|
||||
last_access_time: '2025-01-15T10:00:00Z',
|
||||
user_metadata: { base_model: 'sd15' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-01T09:00:00Z',
|
||||
updated_at: '2025-03-01T09:00:00Z',
|
||||
last_access_time: '2025-03-01T09:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:00:00Z',
|
||||
updated_at: '2025-03-10T12:00:00Z',
|
||||
last_access_time: '2025-03-10T12:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
export const STABLE_CHECKPOINT: Asset = createModelAsset({
|
||||
id: 'test-checkpoint-001',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
size: 6_938_078_208,
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'Stable Diffusion XL Base 1.0'
|
||||
},
|
||||
created_at: '2025-01-15T10:30:00Z',
|
||||
updated_at: '2025-01-15T10:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_CHECKPOINT_2: Asset = createModelAsset({
|
||||
id: 'test-checkpoint-002',
|
||||
name: 'v1-5-pruned-emaonly.safetensors',
|
||||
size: 4_265_146_304,
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Stable Diffusion 1.5 Pruned EMA-Only'
|
||||
},
|
||||
created_at: '2025-01-20T08:00:00Z',
|
||||
updated_at: '2025-01-20T08:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA: Asset = createModelAsset({
|
||||
id: 'test-lora-001',
|
||||
name: 'detail_enhancer_v1.2.safetensors',
|
||||
size: 184_549_376,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'Detail Enhancement LoRA'
|
||||
},
|
||||
created_at: '2025-02-20T14:00:00Z',
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA_2: Asset = createModelAsset({
|
||||
id: 'test-lora-002',
|
||||
name: 'add_detail_v2.safetensors',
|
||||
size: 226_492_416,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Add Detail LoRA v2'
|
||||
},
|
||||
created_at: '2025-02-25T11:00:00Z',
|
||||
updated_at: '2025-02-25T11:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_VAE: Asset = createModelAsset({
|
||||
id: 'test-vae-001',
|
||||
name: 'sdxl_vae.safetensors',
|
||||
size: 334_641_152,
|
||||
tags: ['models', 'vae'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'SDXL VAE'
|
||||
},
|
||||
created_at: '2025-01-18T16:00:00Z',
|
||||
updated_at: '2025-01-18T16:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_EMBEDDING: Asset = createModelAsset({
|
||||
id: 'test-embedding-001',
|
||||
name: 'bad_prompt_v2.pt',
|
||||
size: 32_768,
|
||||
mime_type: 'application/x-pytorch',
|
||||
tags: ['models', 'embeddings'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Negative Embedding: Bad Prompt v2'
|
||||
},
|
||||
created_at: '2025-02-01T09:30:00Z',
|
||||
updated_at: '2025-02-01T09:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-01T09:00:00Z',
|
||||
updated_at: '2025-03-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
|
||||
id: 'test-input-002',
|
||||
name: 'mask_layer.png',
|
||||
size: 1_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-05T10:00:00Z',
|
||||
updated_at: '2025-03-05T10:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
|
||||
id: 'test-input-003',
|
||||
name: 'clip_720p.mp4',
|
||||
size: 15_728_640,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-08T14:30:00Z',
|
||||
updated_at: '2025-03-08T14:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
id: 'test-output-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:00:00Z',
|
||||
updated_at: '2025-03-10T12:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
|
||||
id: 'test-output-002',
|
||||
name: 'ComfyUI_00002_.png',
|
||||
size: 3_670_016,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:05:00Z',
|
||||
updated_at: '2025-03-10T12:05:00Z'
|
||||
})
|
||||
export const ALL_MODEL_FIXTURES: Asset[] = [
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2,
|
||||
STABLE_LORA,
|
||||
STABLE_LORA_2,
|
||||
STABLE_VAE,
|
||||
STABLE_EMBEDDING
|
||||
]
|
||||
|
||||
export const ALL_INPUT_FIXTURES: Asset[] = [
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_INPUT_IMAGE_2,
|
||||
STABLE_INPUT_VIDEO
|
||||
]
|
||||
|
||||
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
|
||||
const CHECKPOINT_NAMES = [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_refiner_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVision_v51.safetensors',
|
||||
'deliberate_v3.safetensors',
|
||||
'anything_v5.safetensors',
|
||||
'counterfeit_v3.safetensors',
|
||||
'revAnimated_v122.safetensors',
|
||||
'majicmixRealistic_v7.safetensors'
|
||||
]
|
||||
|
||||
const LORA_NAMES = [
|
||||
'detail_enhancer_v1.2.safetensors',
|
||||
'add_detail_v2.safetensors',
|
||||
'epi_noiseoffset_v2.safetensors',
|
||||
'lcm_lora_sdxl.safetensors',
|
||||
'film_grain_v1.safetensors',
|
||||
'sharpness_fix_v2.safetensors',
|
||||
'better_hands_v1.safetensors',
|
||||
'smooth_skin_v3.safetensors',
|
||||
'color_pop_v1.safetensors',
|
||||
'bokeh_effect_v2.safetensors'
|
||||
]
|
||||
|
||||
const INPUT_NAMES = [
|
||||
'reference_photo.png',
|
||||
'mask_layer.png',
|
||||
'clip_720p.mp4',
|
||||
'depth_map.png',
|
||||
'control_pose.png',
|
||||
'sketch_input.jpg',
|
||||
'inpainting_mask.png',
|
||||
'style_reference.png',
|
||||
'batch_001.png',
|
||||
'batch_002.png'
|
||||
]
|
||||
|
||||
const EXTENSION_MIME_MAP: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac'
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N deterministic model assets of a given category.
|
||||
* Uses sequential IDs and fixed names for screenshot stability.
|
||||
*/
|
||||
export function generateModels(
|
||||
count: number,
|
||||
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
|
||||
): Asset[] {
|
||||
const names = category === 'loras' ? LORA_NAMES : CHECKPOINT_NAMES
|
||||
return Array.from({ length: Math.min(count, names.length) }, (_, i) =>
|
||||
createModelAsset({
|
||||
id: `gen-${category}-${String(i + 1).padStart(3, '0')}`,
|
||||
name: names[i % names.length],
|
||||
size: 2_000_000_000 + i * 500_000_000,
|
||||
tags: ['models', category],
|
||||
user_metadata: { base_model: i % 2 === 0 ? 'sdxl' : 'sd15' },
|
||||
created_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`,
|
||||
updated_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N deterministic input file assets.
|
||||
*/
|
||||
export function generateInputFiles(count: number): Asset[] {
|
||||
return Array.from({ length: Math.min(count, INPUT_NAMES.length) }, (_, i) => {
|
||||
const name = INPUT_NAMES[i % INPUT_NAMES.length]
|
||||
return createInputAsset({
|
||||
id: `gen-input-${String(i + 1).padStart(3, '0')}`,
|
||||
name,
|
||||
size: 1_000_000 + i * 500_000,
|
||||
mime_type: getMimeType(name),
|
||||
tags: ['input'],
|
||||
created_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`,
|
||||
updated_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N deterministic output assets.
|
||||
*/
|
||||
export function generateOutputAssets(count: number): Asset[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createOutputAsset({
|
||||
id: `gen-output-${String(i + 1).padStart(3, '0')}`,
|
||||
name: `ComfyUI_${String(i + 1).padStart(5, '0')}_.png`,
|
||||
size: 3_000_000 + i * 200_000,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
|
||||
updated_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,60 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from './BuilderStepsHelper'
|
||||
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
||||
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
/** Enable the linear mode feature flag and top menu. */
|
||||
async enableLinearMode() {
|
||||
await this.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown. */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: /Build app|Edit app/ })
|
||||
.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Toggle app mode (linear view) on/off. */
|
||||
async toggleAppMode() {
|
||||
await this.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await this.comfyPage.workflow.waitForActiveWorkflow()
|
||||
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -78,11 +93,61 @@ export class AppModeHelper {
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
get connectOutputPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
||||
}
|
||||
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
get outputPlaceholder(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.outputPlaceholder)
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
}
|
||||
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
get imagePickerPopover(): Locator {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
}
|
||||
|
||||
/** The Run button in the app mode footer. */
|
||||
get runButton(): Locator {
|
||||
return this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
}
|
||||
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
get welcome(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.welcome)
|
||||
}
|
||||
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
get emptyWorkflowText(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
|
||||
}
|
||||
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
get buildAppButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
}
|
||||
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
get backToWorkflowButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
|
||||
}
|
||||
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
get loadTemplateButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.loadTemplate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
|
||||
93
browser_tests/fixtures/helpers/AppModeWidgetHelper.ts
Normal file
93
browser_tests/fixtures/helpers/AppModeWidgetHelper.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
private get container(): Locator {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
getWidgetItem(key: string): Locator {
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
}
|
||||
|
||||
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
|
||||
async fillTextarea(key: string, value: string) {
|
||||
const widget = this.getWidgetItem(key)
|
||||
await widget.locator('textarea').fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number input widget value (INT or FLOAT).
|
||||
* Targets the last input inside the widget — this works for both
|
||||
* ScrubableNumberInput (single input) and slider+InputNumber combos
|
||||
* (last input is the editable number field).
|
||||
*/
|
||||
async fillNumber(key: string, value: string) {
|
||||
const widget = this.getWidgetItem(key)
|
||||
const input = widget.locator('input').last()
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
/** Fill a string text input widget (e.g. filename_prefix). */
|
||||
async fillText(key: string, value: string) {
|
||||
const widget = this.getWidgetItem(key)
|
||||
await widget.locator('input').fill(value)
|
||||
}
|
||||
|
||||
/** Select an option from a combo/select widget. */
|
||||
async selectOption(key: string, optionName: string) {
|
||||
const widget = this.getWidgetItem(key)
|
||||
await widget.getByRole('combobox').click()
|
||||
await this.page
|
||||
.getByRole('option', { name: optionName, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept the /api/prompt POST, click Run, and return the prompt payload.
|
||||
* Fulfills the route with a mock success response.
|
||||
*/
|
||||
async runAndCapturePrompt(): Promise<
|
||||
Record<string, { inputs: Record<string, unknown> }>
|
||||
> {
|
||||
let promptBody: Record<string, { inputs: Record<string, unknown> }> | null =
|
||||
null
|
||||
await this.page.route(
|
||||
'**/api/prompt',
|
||||
async (route, req) => {
|
||||
promptBody = req.postDataJSON().prompt
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
prompt_id: 'test-id',
|
||||
number: 1,
|
||||
node_errors: {}
|
||||
})
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
const responsePromise = this.page.waitForResponse('**/api/prompt')
|
||||
await this.comfyPage.appMode.runButton.click()
|
||||
await responsePromise
|
||||
|
||||
if (!promptBody) throw new Error('No prompt payload captured')
|
||||
return promptBody
|
||||
}
|
||||
}
|
||||
317
browser_tests/fixtures/helpers/AssetHelper.ts
Normal file
317
browser_tests/fixtures/helpers/AssetHelper.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Asset,
|
||||
ListAssetsResponse,
|
||||
UpdateAssetData
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
generateModels,
|
||||
generateInputFiles,
|
||||
generateOutputAssets
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
export interface MutationRecord {
|
||||
endpoint: string
|
||||
method: string
|
||||
url: string
|
||||
body: unknown
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface PaginationOptions {
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
export interface AssetConfig {
|
||||
readonly assets: ReadonlyMap<string, Asset>
|
||||
readonly pagination: PaginationOptions | null
|
||||
readonly uploadResponse: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function emptyConfig(): AssetConfig {
|
||||
return { assets: new Map(), pagination: null, uploadResponse: null }
|
||||
}
|
||||
|
||||
export type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
for (const asset of newAssets) {
|
||||
merged.set(asset.id, asset)
|
||||
}
|
||||
return { ...config, assets: merged }
|
||||
}
|
||||
export function withModels(
|
||||
countOrAssets: number | Asset[],
|
||||
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
|
||||
): AssetOperator {
|
||||
return (config) => {
|
||||
const assets =
|
||||
typeof countOrAssets === 'number'
|
||||
? generateModels(countOrAssets, category)
|
||||
: countOrAssets
|
||||
return addAssets(config, assets)
|
||||
}
|
||||
}
|
||||
|
||||
export function withInputFiles(countOrAssets: number | Asset[]): AssetOperator {
|
||||
return (config) => {
|
||||
const assets =
|
||||
typeof countOrAssets === 'number'
|
||||
? generateInputFiles(countOrAssets)
|
||||
: countOrAssets
|
||||
return addAssets(config, assets)
|
||||
}
|
||||
}
|
||||
|
||||
export function withOutputAssets(
|
||||
countOrAssets: number | Asset[]
|
||||
): AssetOperator {
|
||||
return (config) => {
|
||||
const assets =
|
||||
typeof countOrAssets === 'number'
|
||||
? generateOutputAssets(countOrAssets)
|
||||
: countOrAssets
|
||||
return addAssets(config, assets)
|
||||
}
|
||||
}
|
||||
|
||||
export function withAsset(asset: Asset): AssetOperator {
|
||||
return (config) => addAssets(config, [asset])
|
||||
}
|
||||
|
||||
export function withPagination(options: PaginationOptions): AssetOperator {
|
||||
return (config) => ({ ...config, pagination: options })
|
||||
}
|
||||
|
||||
export function withUploadResponse(
|
||||
response: Record<string, unknown>
|
||||
): AssetOperator {
|
||||
return (config) => ({ ...config, uploadResponse: response })
|
||||
}
|
||||
export class AssetHelper {
|
||||
private store: Map<string, Asset>
|
||||
private paginationOptions: PaginationOptions | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
private mutations: MutationRecord[] = []
|
||||
private uploadResponse: Record<string, unknown> | null
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: AssetConfig = emptyConfig()
|
||||
) {
|
||||
this.store = new Map(config.assets)
|
||||
this.paginationOptions = config.pagination
|
||||
this.uploadResponse = config.uploadResponse
|
||||
}
|
||||
async mock(): Promise<void> {
|
||||
const handler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const method = route.request().method()
|
||||
const path = url.pathname
|
||||
const isMutation = ['POST', 'PUT', 'DELETE'].includes(method)
|
||||
let body: Record<string, unknown> | null = null
|
||||
if (isMutation) {
|
||||
try {
|
||||
body = route.request().postDataJSON()
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
|
||||
if (isMutation) {
|
||||
this.mutations.push({
|
||||
endpoint: path,
|
||||
method,
|
||||
url: route.request().url(),
|
||||
body,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'GET' && /\/assets\/?$/.test(path))
|
||||
return this.handleListAssets(route, url)
|
||||
if (method === 'GET' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleGetAsset(route, path)
|
||||
if (method === 'PUT' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleUpdateAsset(route, path, body)
|
||||
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleDeleteAsset(route, path)
|
||||
if (method === 'POST' && /\/assets\/?$/.test(path))
|
||||
return this.handleUploadAsset(route)
|
||||
if (method === 'POST' && path.endsWith('/assets/download'))
|
||||
return this.handleDownloadAsset(route)
|
||||
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
const pattern = '**/assets**'
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockError(
|
||||
statusCode: number,
|
||||
error: string = 'Internal Server Error'
|
||||
): Promise<void> {
|
||||
const handler = async (route: Route) => {
|
||||
return route.fulfill({
|
||||
status: statusCode,
|
||||
json: { error }
|
||||
})
|
||||
}
|
||||
|
||||
const pattern = '**/assets**'
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
async fetch(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return this.page.evaluate(
|
||||
async ([fetchUrl, fetchInit]) => {
|
||||
const res = await fetch(fetchUrl, fetchInit)
|
||||
const text = await res.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
return { status: res.status, body }
|
||||
},
|
||||
[path, init] as const
|
||||
)
|
||||
}
|
||||
|
||||
configure(...operators: AssetOperator[]): void {
|
||||
const config = operators.reduce<AssetConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.store = new Map(config.assets)
|
||||
this.paginationOptions = config.pagination
|
||||
this.uploadResponse = config.uploadResponse
|
||||
}
|
||||
|
||||
getMutations(): MutationRecord[] {
|
||||
return [...this.mutations]
|
||||
}
|
||||
|
||||
getAssets(): Asset[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
getAsset(id: string): Asset | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
get assetCount(): number {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets: filtered,
|
||||
total: this.paginationOptions?.total ?? this.store.size,
|
||||
has_more: this.paginationOptions?.hasMore ?? false
|
||||
}
|
||||
return route.fulfill({ json: response })
|
||||
}
|
||||
|
||||
private handleGetAsset(route: Route, path: string) {
|
||||
const id = path.split('/').pop()!
|
||||
const asset = this.store.get(id)
|
||||
if (asset) return route.fulfill({ json: asset })
|
||||
return route.fulfill({ status: 404, json: { error: 'Not found' } })
|
||||
}
|
||||
|
||||
private handleUpdateAsset(
|
||||
route: Route,
|
||||
path: string,
|
||||
body: UpdateAssetData['body'] | null
|
||||
) {
|
||||
const id = path.split('/').pop()!
|
||||
const asset = this.store.get(id)
|
||||
if (asset) {
|
||||
const updated = {
|
||||
...asset,
|
||||
...(body ?? {}),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
this.store.set(id, updated)
|
||||
return route.fulfill({ json: updated })
|
||||
}
|
||||
return route.fulfill({ status: 404, json: { error: 'Not found' } })
|
||||
}
|
||||
|
||||
private handleDeleteAsset(route: Route, path: string) {
|
||||
const id = path.split('/').pop()!
|
||||
this.store.delete(id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
|
||||
private handleUploadAsset(route: Route) {
|
||||
const response = this.uploadResponse ?? {
|
||||
id: `upload-${Date.now()}`,
|
||||
name: 'uploaded_file.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: new Date().toISOString(),
|
||||
created_new: true
|
||||
}
|
||||
return route.fulfill({ status: 201, json: response })
|
||||
}
|
||||
|
||||
private handleDownloadAsset(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
json: {
|
||||
task_id: 'download-task-001',
|
||||
status: 'created',
|
||||
message: 'Download started'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.store.clear()
|
||||
this.mutations = []
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
): AssetHelper {
|
||||
const config = operators.reduce<AssetConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new AssetHelper(page, config)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -99,41 +126,89 @@ export class BuilderSelectHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
/**
|
||||
* Click a widget on the canvas to select it as a builder input.
|
||||
* @param nodeTitle The displayed title of the node.
|
||||
* @param widgetName The widget name to click.
|
||||
*/
|
||||
async selectInputWidget(nodeTitle: string, widgetName: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
/**
|
||||
* Get the subtitle locator for a builder IoItem by its title text.
|
||||
* Useful for asserting "Widget not visible" on disconnected inputs.
|
||||
*/
|
||||
getInputItemSubtitle(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.ioItemSubtitle)
|
||||
}
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
get inputItems(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItem)
|
||||
}
|
||||
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
}
|
||||
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
get previewWidgetLabels(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an IoItem from one index to another in the inputs step.
|
||||
* Items are identified by their 0-based position among visible IoItems.
|
||||
*/
|
||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a widget item from one index to another in the preview/arrange step.
|
||||
*/
|
||||
async dragPreviewItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.widgetItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an output node on the canvas to select it as a builder output.
|
||||
* @param nodeTitle The displayed title of the output node.
|
||||
*/
|
||||
async selectOutputNode(nodeTitle: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
|
||||
import type { Position } from '../types'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
export class CanvasHelper {
|
||||
constructor(
|
||||
|
||||
@@ -3,8 +3,8 @@ import { basename } from 'path'
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from './KeyboardHelper'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
|
||||
171
browser_tests/fixtures/helpers/CloudAuthHelper.ts
Normal file
171
browser_tests/fixtures/helpers/CloudAuthHelper.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Mocks Firebase authentication for cloud E2E tests.
|
||||
*
|
||||
* The cloud build's router guard waits for Firebase `onAuthStateChanged`
|
||||
* to fire, then checks `getAuthHeader()`. In CI no Firebase project is
|
||||
* configured, so the user is never authenticated and the app redirects
|
||||
* to `/cloud/login`.
|
||||
*
|
||||
* This helper seeds Firebase's IndexedDB persistence layer with a mock
|
||||
* user and intercepts the Firebase REST APIs (securetoken, identitytoolkit)
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
*/
|
||||
async mockAuth(): Promise<void> {
|
||||
await this.seedFirebaseIndexedDB()
|
||||
await this.mockFirebaseEndpoints()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a lightweight same-origin page to seed Firebase's
|
||||
* IndexedDB persistence with a mock user. This ensures the data
|
||||
* is written before the app loads and Firebase reads it.
|
||||
*
|
||||
* Firebase auth uses `browserLocalPersistence` which stores data in
|
||||
* IndexedDB database `firebaseLocalStorageDb`, object store
|
||||
* `firebaseLocalStorage`, keyed by `firebase:authUser:<apiKey>:<appName>`.
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
uid: 'test-user-e2e',
|
||||
email: 'e2e@test.comfy.org',
|
||||
displayName: 'E2E Test User',
|
||||
emailVerified: true,
|
||||
isAnonymous: false,
|
||||
providerData: [
|
||||
{
|
||||
providerId: 'google.com',
|
||||
uid: 'test-user-e2e',
|
||||
displayName: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
phoneNumber: null,
|
||||
photoURL: null
|
||||
}
|
||||
],
|
||||
stsTokenManager: {
|
||||
refreshToken: 'mock-refresh-token',
|
||||
accessToken: 'mock-firebase-id-token',
|
||||
expirationTime: Date.now() + 60 * 60 * 1000
|
||||
},
|
||||
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
|
||||
appName: '[DEFAULT]'
|
||||
}
|
||||
|
||||
const DB_NAME = 'firebaseLocalStorageDb'
|
||||
const STORE_NAME = 'firebaseLocalStorage'
|
||||
const KEY = `firebase:authUser:${MOCK_USER_DATA.apiKey}:${MOCK_USER_DATA.appName}`
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.close()
|
||||
const upgradeReq = indexedDB.open(DB_NAME, db.version + 1)
|
||||
upgradeReq.onerror = () => reject(upgradeReq.error)
|
||||
upgradeReq.onupgradeneeded = () => {
|
||||
const upgradedDb = upgradeReq.result
|
||||
if (!upgradedDb.objectStoreNames.contains(STORE_NAME)) {
|
||||
upgradedDb.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
upgradeReq.onsuccess = () => {
|
||||
const upgradedDb = upgradeReq.result
|
||||
const tx = upgradedDb.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(
|
||||
{ fpiVersion: '1', value: MOCK_USER_DATA },
|
||||
KEY
|
||||
)
|
||||
tx.oncomplete = () => {
|
||||
upgradedDb.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(
|
||||
{ fpiVersion: '1', value: MOCK_USER_DATA },
|
||||
KEY
|
||||
)
|
||||
tx.oncomplete = () => {
|
||||
db.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept Firebase Auth REST API endpoints so the SDK can
|
||||
* "refresh" the mock user's token without real credentials.
|
||||
*/
|
||||
private async mockFirebaseEndpoints(): Promise<void> {
|
||||
await this.page.route('**/securetoken.googleapis.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: 'mock-access-token',
|
||||
expires_in: '3600',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'mock-refresh-token',
|
||||
id_token: 'mock-firebase-id-token',
|
||||
user_id: 'test-user-e2e',
|
||||
project_id: 'dreamboothy-dev'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await this.page.route('**/identitytoolkit.googleapis.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
kind: 'identitytoolkit#GetAccountInfoResponse',
|
||||
users: [
|
||||
{
|
||||
localId: 'test-user-e2e',
|
||||
email: 'e2e@test.comfy.org',
|
||||
displayName: 'E2E Test User',
|
||||
emailVerified: true,
|
||||
validSince: '0',
|
||||
lastLoginAt: String(Date.now()),
|
||||
createdAt: String(Date.now()),
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await this.page.route('**/__/auth/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html><body></body></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { KeyCombo } from '../../../src/platform/keybindings/types'
|
||||
import type { KeyCombo } from '@/platform/keybindings/types'
|
||||
|
||||
export class CommandHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
@@ -41,6 +41,7 @@ export class CommandHelper {
|
||||
commands: [
|
||||
{
|
||||
id: commandId,
|
||||
// oxlint-disable-next-line no-eval -- intentional: eval reconstructs a serialized function inside Playwright's page context
|
||||
function: eval(commandStr)
|
||||
}
|
||||
]
|
||||
@@ -76,6 +77,7 @@ export class CommandHelper {
|
||||
commands: [
|
||||
{
|
||||
id: commandId,
|
||||
// oxlint-disable-next-line no-eval -- intentional: eval reconstructs a serialized function inside Playwright's page context
|
||||
function: eval(commandStr)
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,9 +2,9 @@ import { readFileSync } from 'fs'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
import { assetPath } from '../utils/paths'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
25
browser_tests/fixtures/helpers/Load3DFixtures.ts
Normal file
25
browser_tests/fixtures/helpers/Load3DFixtures.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
|
||||
import { Load3DViewerHelper } from '@e2e/tests/load3d/Load3DViewerHelper'
|
||||
|
||||
export const load3dTest = comfyPageFixture.extend<{
|
||||
load3d: Load3DHelper
|
||||
}>({
|
||||
load3d: async ({ comfyPage }, use) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await use(new Load3DHelper(node))
|
||||
}
|
||||
})
|
||||
|
||||
export const load3dViewerTest = load3dTest.extend<{
|
||||
viewer: Load3DViewerHelper
|
||||
}>({
|
||||
viewer: async ({ comfyPage }, use) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Load3D.3DViewerEnable', true)
|
||||
await use(new Load3DViewerHelper(comfyPage.page))
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
} from '../../../src/platform/assets/schemas/assetSchema'
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const modelFoldersRoutePattern = /\/api\/experiment\/models$/
|
||||
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '../../../src/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '../types'
|
||||
import { NodeReference } from '../utils/litegraphUtils'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class NodeOperationsHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
@@ -33,6 +30,12 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
await this.comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
}
|
||||
|
||||
/** Reads from `window.app.graph` (the root workflow graph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => window.app!.graph.nodes.length)
|
||||
|
||||
@@ -7,10 +7,10 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '../utils/litegraphUtils'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class SubgraphHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -445,7 +445,7 @@ export class SubgraphHelper {
|
||||
await this.rightClickOutputSlot(slotName)
|
||||
}
|
||||
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.contextMenu.waitForHidden()
|
||||
}
|
||||
|
||||
async findSubgraphNodeId(): Promise<string> {
|
||||
@@ -484,7 +484,16 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.page.evaluate(() => {
|
||||
@@ -493,7 +502,16 @@ export class SubgraphHelper {
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.exitViaBreadcrumb()
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,8 @@ export class ToastHelper {
|
||||
return this.page.locator('.p-toast-message:visible')
|
||||
}
|
||||
|
||||
async getToastErrorCount(): Promise<number> {
|
||||
return await this.page
|
||||
.locator('.p-toast-message.p-toast-message-error')
|
||||
.count()
|
||||
}
|
||||
|
||||
async getVisibleToastCount(): Promise<number> {
|
||||
return await this.visibleToasts.count()
|
||||
get toastErrors(): Locator {
|
||||
return this.page.locator('.p-toast-message.p-toast-message-error')
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0): Promise<void> {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import type { AppMode } from '../../../src/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { assetPath } from '../utils/paths'
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type FolderStructure = {
|
||||
[key: string]: FolderStructure | string
|
||||
@@ -58,6 +58,16 @@ export class WorkflowHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async waitForDraftPersisted({ timeout = 5000 } = {}) {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
),
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
assetPath(`${workflowName}.json`)
|
||||
@@ -106,6 +116,14 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async waitForActiveWorkflow(): Promise<void> {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
(window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow !== null
|
||||
)
|
||||
}
|
||||
|
||||
async getActiveWorkflowPath(): Promise<string | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
|
||||
@@ -41,10 +41,21 @@ export const TestIds = {
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
errorDialogShowReport: 'error-dialog-show-report',
|
||||
errorDialogContactSupport: 'error-dialog-contact-support',
|
||||
errorDialogCopyReport: 'error-dialog-copy-report',
|
||||
errorDialogFindIssues: 'error-dialog-find-issues',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
missingModelLocate: 'missing-model-locate',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
@@ -52,7 +63,8 @@ export const TestIds = {
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button'
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -110,8 +122,21 @@ export const TestIds = {
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
ioItemSubtitle: 'builder-io-item-subtitle',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
opensAs: 'builder-opens-as',
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label',
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
},
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item',
|
||||
welcome: 'linear-welcome',
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
backToWorkflow: 'linear-welcome-back-to-workflow',
|
||||
loadTemplate: 'linear-welcome-load-template'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
@@ -130,6 +155,12 @@ export const TestIds = {
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
loading: {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3dViewer: {
|
||||
sidebar: 'load3d-viewer-sidebar'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -149,6 +180,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
@@ -159,3 +191,5 @@ export type TestIdValue =
|
||||
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
|
||||
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ExpectMatcherState, Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
function makeMatcher<T>(
|
||||
getValue: (node: NodeReference) => Promise<T> | T,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '../../helpers/manageGroupNode'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { Position, Size } from '../types'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
|
||||
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../selectors'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
import { backupPath } from '@e2e/utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
import { writePerfReport } from '@e2e/helpers/perfReporter'
|
||||
import { restorePath } from '@e2e/utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { AutoQueueMode } from '../../src/stores/queueStore'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
import type { AutoQueueMode } from '@/stores/queueStore'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
export class ComfyActionbar {
|
||||
public readonly root: Locator
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
@@ -13,55 +19,97 @@ import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
* @param prepareGraph - Optional callback to transform the graph before
|
||||
* entering builder. Receives the KSampler node ref and returns the
|
||||
* input node title and widget names to select.
|
||||
* Defaults to KSampler with its first widget.
|
||||
* Mutually exclusive with widgetNames.
|
||||
* @param widgetNames - Widget names to select from the KSampler node.
|
||||
* Only used when prepareGraph is not provided.
|
||||
* Mutually exclusive with prepareGraph.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
prepareGraph?: (ksampler: NodeReference) => Promise<BuilderSetupResult>,
|
||||
widgetNames?: string[]
|
||||
): Promise<void> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
const { inputNodeTitle, widgetNames: inputWidgets } = prepareGraph
|
||||
? await prepareGraph(ksampler)
|
||||
: { inputNodeTitle: 'KSampler', widgetNames: widgetNames ?? ['seed'] }
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
for (const name of inputWidgets) {
|
||||
await appMode.select.selectInputWidget(inputNodeTitle, name)
|
||||
}
|
||||
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
): Promise<void> {
|
||||
await setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
return {
|
||||
inputNodeTitle: 'New Subgraph',
|
||||
widgetNames: ['seed']
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
export async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph' = 'App'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
export async function openWorkflowFromSidebar(
|
||||
comfyPage: ComfyPage,
|
||||
name: string
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toContain(name)
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
19
browser_tests/helpers/clipboardSpy.ts
Normal file
19
browser_tests/helpers/clipboardSpy.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export async function interceptClipboardWrite(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const w = window as Window & { __copiedText?: string }
|
||||
w.__copiedText = ''
|
||||
navigator.clipboard.writeText = async (text: string) => {
|
||||
w.__copiedText = text
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getClipboardText(page: Page): Promise<string> {
|
||||
return (
|
||||
(await page.evaluate(
|
||||
() => (window as Window & { __copiedText?: string }).__copiedText
|
||||
)) ?? ''
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
interface FitToViewOptions {
|
||||
selectionOnly?: boolean
|
||||
|
||||
@@ -26,9 +26,12 @@ export class ManageGroupNode {
|
||||
await this.footer.getByText('Close').click()
|
||||
}
|
||||
|
||||
get selectedNodeTypeSelect(): Locator {
|
||||
return this.header.locator('select').first()
|
||||
}
|
||||
|
||||
async getSelectedNodeType() {
|
||||
const select = this.header.locator('select').first()
|
||||
return await select.inputValue()
|
||||
return await this.selectedNodeTypeSelect.inputValue()
|
||||
}
|
||||
|
||||
async selectNode(name: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
|
||||
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
|
||||
export interface PerfReport {
|
||||
timestamp: string
|
||||
@@ -41,6 +41,7 @@ export function logMeasurement(
|
||||
if (formatter) return formatter(m)
|
||||
return `${f}=${m[f]}`
|
||||
})
|
||||
// oxlint-disable-next-line no-console -- perf reporter intentionally logs to stdout
|
||||
console.log(`${label}: ${parts.join(', ')}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
|
||||
export interface PromotedWidgetSnapshot {
|
||||
proxyWidgets: PromotedWidgetEntry[]
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
@@ -18,9 +11,7 @@ export function isPromotedWidgetEntry(
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizePromotedWidgets(
|
||||
value: unknown
|
||||
): PromotedWidgetEntry[] {
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
@@ -31,34 +22,29 @@ export async function getPromotedWidgets(
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return node?.properties?.proxyWidgets ?? []
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetSnapshot> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return {
|
||||
proxyWidgets: node?.properties?.proxyWidgets ?? [],
|
||||
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
|
||||
}
|
||||
}, nodeId)
|
||||
|
||||
return {
|
||||
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
|
||||
widgetNames: Array.isArray(raw.widgetNames)
|
||||
? raw.widgetNames.filter(
|
||||
(name): name is string => typeof name === 'string'
|
||||
)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -75,7 +61,7 @@ export async function getPromotedWidgetCount(
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
return entry[1].startsWith('$$')
|
||||
}
|
||||
|
||||
@@ -87,14 +73,6 @@ export async function getPseudoPreviewWidgets(
|
||||
return widgets.filter(isPseudoPreviewEntry)
|
||||
}
|
||||
|
||||
export async function getNonPreviewPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
|
||||
@@ -5,8 +5,8 @@ import path from 'path'
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '../../src/platform/workflow/templates/types/template'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class ComfyTemplates {
|
||||
readonly content: Locator
|
||||
@@ -18,12 +18,9 @@ export class ComfyTemplates {
|
||||
}
|
||||
|
||||
async expectMinimumCardCount(count: number) {
|
||||
await expect(async () => {
|
||||
const cardCount = await this.allTemplateCards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(count)
|
||||
}).toPass({
|
||||
timeout: 1_000
|
||||
})
|
||||
await expect
|
||||
.poll(() => this.allTemplateCards.count())
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async loadTemplate(id: string) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
import type { StatusWsMessage } from '@/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -22,10 +22,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
// Enable change auto-queue mode
|
||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
expect(await queueOpts.getMode()).toBe('disabled')
|
||||
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
|
||||
await queueOpts.setMode('change')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await queueOpts.getMode()).toBe('change')
|
||||
await expect.poll(() => queueOpts.getMode()).toBe('change')
|
||||
await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
|
||||
// Intercept the prompt queue endpoint
|
||||
@@ -124,6 +124,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
force: true
|
||||
}
|
||||
)
|
||||
expect(await comfyPage.actionbar.isDocked()).toBe(true)
|
||||
await expect(comfyPage.actionbar.root.locator('.actionbar')).toHaveClass(
|
||||
/static/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
@@ -60,13 +60,7 @@ async function addNode(page: Page, nodeType: string): Promise<string> {
|
||||
|
||||
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Select dropdown is not clipped in app mode panel', async ({
|
||||
@@ -98,19 +92,23 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
await expect
|
||||
.poll(() =>
|
||||
overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
.toBe(true)
|
||||
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
await expect
|
||||
.poll(() => overlay.evaluate(isClippedByAnyAncestor))
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
@@ -145,24 +143,25 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
await expect
|
||||
.poll(() =>
|
||||
popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
.toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
await expect
|
||||
.poll(() => popover.evaluate(isClippedByAnyAncestor))
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
105
browser_tests/tests/appModePruning.spec.ts
Normal file
105
browser_tests/tests/appModePruning.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
|
||||
const RESIZE_NODE_ID = '1'
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
/**
|
||||
* Load the dynamic combo workflow, enter builder,
|
||||
* select a dynamic sub-widget as input and SaveImage as output.
|
||||
*/
|
||||
async function setupDynamicComboBuilder(comfyPage: ComfyPage) {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('inputs/dynamic_combo')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(RESIZE_NODE_TITLE, 'resize_type.width')
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
}
|
||||
|
||||
test.describe('App Mode Pruning', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('prunes deleted outputs', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
// Enter builder with default workflow (seed input + SaveImage output)
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// Verify save-as dialog opens
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.dialog).toBeVisible()
|
||||
await appMode.saveAs.dialog.press('Escape')
|
||||
|
||||
// Exit builder, delete SaveImage node
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.vueNodes.deleteNode(SAVE_IMAGE_NODE_ID)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator(SAVE_IMAGE_NODE_ID)
|
||||
).not.toBeAttached()
|
||||
|
||||
// Re-enter builder - pruning should auto-clean stale outputs
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToOutputs()
|
||||
await expect(appMode.outputPlaceholder).toBeVisible()
|
||||
|
||||
// Verify can't save
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not prune missing widgets when node still exists for dynamic widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupDynamicComboBuilder(comfyPage)
|
||||
await appMode.footer.exitBuilder()
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Change dynamic combo from "scale dimensions" to "scale by multiplier"
|
||||
// This removes the width/height widgets and adds factor
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
RESIZE_NODE_TITLE,
|
||||
'resize_type',
|
||||
'scale by multiplier'
|
||||
)
|
||||
|
||||
// Re-enter builder - node exists but widget is gone
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
// The input should still be listed but show "Widget not visible"
|
||||
const subtitle = appMode.select.getInputItemSubtitle('resize_type.width')
|
||||
await expect(subtitle).toHaveText('Widget not visible')
|
||||
})
|
||||
|
||||
test('prunes missing widgets when node deleted', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupDynamicComboBuilder(comfyPage)
|
||||
await appMode.footer.exitBuilder()
|
||||
|
||||
// Delete the ResizeImageMaskNode entirely
|
||||
await comfyPage.vueNodes.deleteNode(RESIZE_NODE_ID)
|
||||
|
||||
// Re-enter builder - pruning should auto-clean stale inputs
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItems).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
61
browser_tests/tests/appModeWelcome.spec.ts
Normal file
61
browser_tests/tests/appModeWelcome.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Build app button is visible when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow and build app are hidden when app has outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.backToWorkflowButton.click()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Load template opens template selector', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.loadTemplateButton.click()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user