Compare commits
71 Commits
test/cov-l
...
cloud/1.44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab9752af8 | ||
|
|
e469611f6d | ||
|
|
ad6cbf7cbe | ||
|
|
5ebf5e03ae | ||
|
|
d3ab2be695 | ||
|
|
37f0fbcbef | ||
|
|
6ef051f200 | ||
|
|
0788e71394 | ||
|
|
d3f802de10 | ||
|
|
d78c630d36 | ||
|
|
aa4343a98b | ||
|
|
270c7e34f4 | ||
|
|
666684e6e6 | ||
|
|
4484b62854 | ||
|
|
d29169ff4e | ||
|
|
3e6f3444e5 | ||
|
|
e46667b33f | ||
|
|
d5121d3fed | ||
|
|
733917d5cf | ||
|
|
08967bc684 | ||
|
|
fb32b9a5c5 | ||
|
|
6474faaa17 | ||
|
|
da6a3e0722 | ||
|
|
eecbaa8f39 | ||
|
|
0e110bec0d | ||
|
|
32984459bf | ||
|
|
0307281ff2 | ||
|
|
21406dceb1 | ||
|
|
14320a131f | ||
|
|
a763c7132c | ||
|
|
3f223dbbb4 | ||
|
|
60f789d580 | ||
|
|
24fc11aa3e | ||
|
|
055486cac0 | ||
|
|
f6ddd26cef | ||
|
|
6822a6883d | ||
|
|
3637b61fcd | ||
|
|
d1df5fadf8 | ||
|
|
7d67fe364b | ||
|
|
7c2321cc23 | ||
|
|
a877ccde94 | ||
|
|
e3883f4a2c | ||
|
|
5e16802832 | ||
|
|
0e9a5ecbe9 | ||
|
|
9013102db9 | ||
|
|
6ea5a5e32d | ||
|
|
90b3d8a5c6 | ||
|
|
551cf21fb1 | ||
|
|
2c8ecd82ec | ||
|
|
7b59c561ff | ||
|
|
8b1d564729 | ||
|
|
ea2e8e59f2 | ||
|
|
1f60f7cfcc | ||
|
|
5e3266e0c2 | ||
|
|
b5b502755f | ||
|
|
5fbcea6b27 | ||
|
|
ac36dc47a4 | ||
|
|
aef71852f0 | ||
|
|
94b570a177 | ||
|
|
846412af17 | ||
|
|
aa2169e108 | ||
|
|
cc24d1411a | ||
|
|
c2abbeda80 | ||
|
|
56ac3762a0 | ||
|
|
f70285dcb2 | ||
|
|
6762c08134 | ||
|
|
211c49f538 | ||
|
|
b83602fd23 | ||
|
|
aee2e6e6dd | ||
|
|
2a3b692c0b | ||
|
|
dac3396de8 |
@@ -9,13 +9,18 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
|
||||
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
6. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
7. **Log & Report** — Generate session report (`reference/logging.md`)
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
|
||||
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
|
||||
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
|
||||
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
|
||||
5. **Analyze** — Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
|
||||
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
|
||||
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
|
||||
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
|
||||
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
|
||||
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
|
||||
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
@@ -107,6 +112,35 @@ Husky hooks fail in worktrees (can't find lint-staged config). Always use `git p
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
|
||||
|
||||
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
|
||||
|
||||
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
|
||||
|
||||
```bash
|
||||
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
|
||||
```
|
||||
|
||||
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
|
||||
|
||||
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
|
||||
|
||||
### Backport-Only Compatibility Shims
|
||||
|
||||
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
|
||||
|
||||
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
|
||||
|
||||
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
|
||||
|
||||
- `node.onXxx` callback assignments
|
||||
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
- Public exports from `src/lib/litegraph/`
|
||||
- Type changes affecting `litegraph-augmentation.d.ts`
|
||||
|
||||
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
@@ -147,6 +181,26 @@ Skip these without discussion:
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
|
||||
|
||||
### Path Pre-Filter (run BEFORE reading PR bodies)
|
||||
|
||||
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
|
||||
|
||||
| Path prefix | Bucket | Reason |
|
||||
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
|
||||
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
|
||||
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
|
||||
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
|
||||
| `.github/workflows/` only | SKIP | CI/release infra |
|
||||
| `packages/design-system/` only | SKIP | Design tokens, not core |
|
||||
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
|
||||
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
|
||||
| `*.stories.ts` only | SKIP | Storybook only |
|
||||
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
|
||||
|
||||
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
|
||||
|
||||
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
@@ -39,6 +39,89 @@ Check before backporting — these don't exist on older branches:
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
### Verify Target File Existence (Run Before Cherry-Pick)
|
||||
|
||||
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
|
||||
|
||||
```bash
|
||||
# For each file the PR changes
|
||||
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
|
||||
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
|
||||
echo "MISSING on $TARGET: $f"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
|
||||
|
||||
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
|
||||
|
||||
## Tiered Triage (Recommended for 30+ Candidates)
|
||||
|
||||
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
|
||||
|
||||
### Tier 1 — Core Editor Must-Haves
|
||||
|
||||
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
|
||||
|
||||
Indicators:
|
||||
|
||||
- `fix:` prefix and the bug is reproducible on the target branch
|
||||
- Crash guards, runtime null checks, race-condition fixes
|
||||
- Data-loss bugs (state not persisted, duplicates, drops)
|
||||
- Security hardening (CSRF, XSS, auth)
|
||||
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
|
||||
- Subgraph correctness fixes
|
||||
- Public-API extension callback fixes
|
||||
|
||||
Recommend `Y` to user.
|
||||
|
||||
### Tier 2 — Cloud-Distribution Only
|
||||
|
||||
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
|
||||
|
||||
Indicators:
|
||||
|
||||
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
|
||||
- PR description mentions cloud staging issues
|
||||
- Fix gated behind cloud feature flags
|
||||
|
||||
Default: ask the cloud release rotation owner. If unsure, defer.
|
||||
|
||||
### Tier 3 — Skip
|
||||
|
||||
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
|
||||
|
||||
Indicators:
|
||||
|
||||
- All changes in test files even if the PR touched `src/` test files
|
||||
- Storybook stories only
|
||||
- Lint config / lint rule additions
|
||||
- Documentation comments
|
||||
- Internal refactors with no behavior change
|
||||
|
||||
### Presentation Format
|
||||
|
||||
When showing tier results to the user, format as:
|
||||
|
||||
```text
|
||||
Tier 1 (N PRs) — strong backport candidates
|
||||
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
|
||||
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
|
||||
- #10849 fix: store promoted widget values per SubgraphNode instance
|
||||
Why: Multiple instances overwriting each other's promoted widget values — data loss
|
||||
|
||||
Tier 2 (N PRs) — cloud-distribution release rotation should decide
|
||||
- #11636 fix: enable Chrome password autofill on signup form
|
||||
- ...
|
||||
|
||||
Tier 3 (N PRs) — skip recommended
|
||||
- #11586 fix: website polish (apps/website/ only)
|
||||
- ...
|
||||
```
|
||||
|
||||
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
|
||||
|
||||
## Dep Refresh PRs
|
||||
|
||||
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
**Run all sources, then reconcile.** No single source is authoritative:
|
||||
|
||||
- Slack bot may flag PRs that have already been backported (false positive)
|
||||
- Git gap may include PRs that don't need backport (test-only, design-system, website)
|
||||
- Bot can also miss PRs that landed without the right labels
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
@@ -36,7 +42,43 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Source 4: Already-Backported PRs (cross-reference)
|
||||
|
||||
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
|
||||
|
||||
```bash
|
||||
# Get all original PR numbers already backported to TARGET since the last release tag
|
||||
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
|
||||
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
|
||||
| grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/already-backported.txt
|
||||
```
|
||||
|
||||
Subtract this list from your candidates.
|
||||
|
||||
## Reconciliation Workflow
|
||||
|
||||
```bash
|
||||
# 1. Slack bot list (parse from export)
|
||||
# /tmp/bot-flagged.txt — one PR# per line, sorted
|
||||
|
||||
# 2. Git gap fix/perf only
|
||||
MB=$(git merge-base origin/main origin/$TARGET)
|
||||
git log --format="%h|%s" $MB..origin/main \
|
||||
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
|
||||
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/gap-fixes.txt
|
||||
|
||||
# 3. Already backported (Source 4 above)
|
||||
|
||||
# 4. Candidates = (gap-fixes ∪ bot-flagged) − already-backported
|
||||
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
|
||||
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
|
||||
```
|
||||
|
||||
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Category | Flagged by Bot? | Decision |
|
||||
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
|
||||
|
||||
@@ -6,6 +6,43 @@
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
|
||||
|
||||
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/dryrun-TARGET
|
||||
|
||||
CLEAN=()
|
||||
CONFLICT=()
|
||||
for pr in "${CANDIDATES[@]}"; do
|
||||
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
|
||||
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
CLEAN+=($pr)
|
||||
else
|
||||
CONFLICT+=($pr)
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD 2>/dev/null
|
||||
git branch -D dryrun-$pr 2>/dev/null
|
||||
done
|
||||
|
||||
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
|
||||
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
|
||||
|
||||
cd -
|
||||
git worktree remove /tmp/dryrun-TARGET --force
|
||||
```
|
||||
|
||||
Use the result to:
|
||||
|
||||
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
|
||||
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
|
||||
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
@@ -88,6 +125,39 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
|
||||
# If the conflict resolution touched any of these surfaces, consult oracle
|
||||
# BEFORE pushing. A bad shim is worse than no fix:
|
||||
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
|
||||
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
|
||||
# - Public exports from src/lib/litegraph/
|
||||
# - Type changes in litegraph-augmentation.d.ts
|
||||
# If a public callback's signature/contract changed: add a backport-only
|
||||
# compatibility shim that preserves the OLD contract while keeping the
|
||||
# new fix. Document it in the commit body under
|
||||
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
|
||||
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
|
||||
if [ ${#CODE_FILES[@]} -gt 0 ]; then
|
||||
pnpm exec eslint "${CODE_FILES[@]}"
|
||||
pnpm exec oxfmt --check "${CODE_FILES[@]}"
|
||||
else
|
||||
echo "No changed ts/vue files — skipping targeted lint/format"
|
||||
fi
|
||||
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
@@ -243,6 +313,9 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
|
||||
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
|
||||
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
@@ -268,3 +341,40 @@ Common failure categories:
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
## PR Body Template (Manual Cherry-Picks)
|
||||
|
||||
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
|
||||
|
||||
```markdown
|
||||
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
|
||||
|
||||
Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
|
||||
|
||||
## Conflict resolution
|
||||
|
||||
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
|
||||
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
|
||||
- [...]
|
||||
|
||||
## Backport-only compatibility fix (if applicable)
|
||||
|
||||
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
|
||||
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
|
||||
|
||||
Original PR: #ORIG / Original commit: `FULL_SHA`
|
||||
```
|
||||
|
||||
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"
|
||||
|
||||
@@ -114,7 +114,7 @@ await expect(async () => {
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `npx playwright show-trace trace.zip`
|
||||
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,10 +54,14 @@ jobs:
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
# PRs run each test once to keep wall time bounded; main runs 3× so the
|
||||
# baseline saved to perf-data has enough samples to median over noise.
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
env:
|
||||
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
@@ -4,6 +4,9 @@ name: 'CI: Vercel Website Preview'
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- 'core/**'
|
||||
- 'cloud/**'
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
|
||||
@@ -3,6 +3,23 @@ import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const LOCALES = ['en', 'zh-CN'] as const
|
||||
const DEFAULT_LOCALE = 'en'
|
||||
const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
return SITEMAP_EXCLUDED_PATHNAMES.has(pathname)
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
@@ -17,7 +34,12 @@ export default defineConfig({
|
||||
assets: '_website'
|
||||
},
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [vue(), sitemap()],
|
||||
integrations: [
|
||||
vue(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
server: {
|
||||
@@ -27,8 +49,8 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
i18n: {
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
locales: [...LOCALES],
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
|
||||
44
apps/website/e2e/demos.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
await expect(page.getByText('Coming Soon')).toBeVisible()
|
||||
})
|
||||
|
||||
test('non-existent demo returns 404', async ({ page }) => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => {
|
||||
await expect(githubBtn).toBeVisible()
|
||||
await expect(githubBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Comfy-Org/ComfyUI'
|
||||
'https://github.com/Comfy-Org/ComfyUI#installing'
|
||||
)
|
||||
|
||||
await context.close()
|
||||
|
||||
115
apps/website/e2e/payment.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const CLOUD_URL = externalLinks.cloud
|
||||
const PLATFORM_USAGE_URL = externalLinks.platformUsage
|
||||
const SUPPORT_URL = externalLinks.support
|
||||
const DOCS_SUBSCRIPTION_URL = externalLinks.docsSubscription
|
||||
|
||||
async function expectNoIndex(page: Page) {
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
|
||||
'content',
|
||||
'noindex, nofollow'
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Payment success page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/payment/success')
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Successful — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
test('shows success heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Payment successful/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/Thanks for your purchase/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary CTA links to Comfy Cloud', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /CONTINUE TO COMFY CLOUD/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', CLOUD_URL)
|
||||
})
|
||||
|
||||
test('secondary CTA links to platform usage & payments page', async ({
|
||||
page
|
||||
}) => {
|
||||
const cta = page.getByRole('link', { name: /VIEW USAGE & PAYMENTS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', PLATFORM_USAGE_URL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Payment failed page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/payment/failed')
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Failed — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
test('shows failure heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: /Unable to complete payment/i,
|
||||
level: 1
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/payment didn't go through/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary CTA links to support help center', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /CONTACT SUPPORT/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', SUPPORT_URL)
|
||||
})
|
||||
|
||||
test('secondary CTA links to subscription docs', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /READ SUBSCRIPTION DOCS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Payment pages zh-CN @smoke', () => {
|
||||
test('zh-CN success page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/success')
|
||||
await expect(page).toHaveTitle('支付成功 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付成功', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: '前往 COMFY CLOUD' })
|
||||
).toHaveAttribute('href', CLOUD_URL)
|
||||
await expect(
|
||||
page.getByRole('link', { name: '查看用量与支付' })
|
||||
).toHaveAttribute('href', PLATFORM_USAGE_URL)
|
||||
})
|
||||
|
||||
test('zh-CN failed page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/failed')
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '无法完成支付', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
|
||||
'href',
|
||||
SUPPORT_URL
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('link', { name: '查看订阅文档' })
|
||||
).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
|
||||
})
|
||||
})
|
||||
@@ -126,6 +126,7 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
|
||||
const pages = [
|
||||
'/',
|
||||
'/cloud',
|
||||
'/cloud/enterprise',
|
||||
'/cloud/pricing',
|
||||
'/contact',
|
||||
'/download',
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 KiB |
@@ -27,6 +27,7 @@
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
BIN
apps/website/public/images/demos/image-to-video-og.png
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/image-to-video-thumb.webp
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/workflow-templates-og.png
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/workflow-templates-thumb.webp
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -29,5 +29,30 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team0.webp',
|
||||
@@ -17,45 +15,34 @@ const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team3.webp',
|
||||
alt: 'Team on a boat'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team4.webp',
|
||||
alt: 'Teammates posing at a restaurant'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team5.webp',
|
||||
alt: 'Teammates at a social gathering'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team6.webp',
|
||||
alt: 'Team sailing at golden hour'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team7.webp',
|
||||
alt: 'Team on a sailboat at sunset'
|
||||
}
|
||||
]
|
||||
|
||||
const loopedPhotos = [...photos, ...photos, ...photos]
|
||||
|
||||
const scrollRef = ref<HTMLElement>()
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el) return
|
||||
|
||||
const third = el.scrollWidth / 3
|
||||
const maxScroll = el.scrollWidth - el.clientWidth
|
||||
|
||||
if (el.scrollLeft >= maxScroll - 1) {
|
||||
el.scrollLeft -= third
|
||||
} else if (el.scrollLeft <= 1) {
|
||||
el.scrollLeft += third
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = scrollRef.value
|
||||
if (el) {
|
||||
el.scrollLeft = el.scrollWidth / 3
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-12 md:py-24">
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
|
||||
style="scrollbar-width: none"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(photo, i) in loopedPhotos"
|
||||
v-for="(photo, i) in photos"
|
||||
:key="i"
|
||||
class="aspect-3/4 h-64 shrink-0 md:h-96"
|
||||
>
|
||||
|
||||
67
apps/website/src/components/demos/ArcadeEmbed.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const loaded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-16"
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
|
||||
>
|
||||
<div
|
||||
class="border-primary-comfy-canvas/60 mb-4 size-10 animate-pulse rounded-full border-2"
|
||||
/>
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
{{ t('demos.loading', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
class="size-full"
|
||||
:src="`https://demo.arcade.software/${arcadeId}?embed&show_title=0`"
|
||||
:title="`${t('demos.embed.label', locale)}: ${title}`"
|
||||
loading="lazy"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@load="loaded = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{{ t('demos.noscript', locale) }}
|
||||
<a
|
||||
class="text-primary-comfy-yellow ml-2 underline"
|
||||
:href="`https://demo.arcade.software/${arcadeId}`"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('demos.noscript.link', locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</noscript>
|
||||
</section>
|
||||
</template>
|
||||
60
apps/website/src/components/demos/DemoHeroSection.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
difficulty,
|
||||
estimatedTime,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedTime: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const difficultyKey = `demos.difficulty.${difficulty}` as TranslationKey
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
|
||||
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{{ t(difficultyKey, locale) }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ t(estimatedTime as TranslationKey, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
59
apps/website/src/components/demos/DemoNavSection.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
nextTitle,
|
||||
nextSlug,
|
||||
nextThumbnail,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
nextTitle: string
|
||||
nextSlug: string
|
||||
nextThumbnail: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
|
||||
>
|
||||
<a :href="nextHref" class="shrink-0 lg:w-1/2">
|
||||
<img
|
||||
:src="nextThumbnail"
|
||||
:alt="nextTitle"
|
||||
class="w-full rounded-4xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
{{ nextTitle }}
|
||||
</h3>
|
||||
|
||||
<a :href="nextHref" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
50
apps/website/src/components/demos/DemoTranscript.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { transcript, locale = 'en' } = defineProps<{
|
||||
transcript: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-12"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-comfy-canvas text-left"
|
||||
:aria-expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<span class="text-sm font-semibold tracking-wide uppercase">
|
||||
{{ t('demos.transcript.label', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-2 text-xs">
|
||||
{{ t('demos.transcript.note', locale) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
:class="
|
||||
cn(
|
||||
expanded ? 'mt-4' : 'sr-only',
|
||||
'text-primary-warm-gray text-sm/relaxed'
|
||||
)
|
||||
"
|
||||
v-html="transcript"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,24 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div class="relative flex-1">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="w-full"
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
101
apps/website/src/components/payment/PaymentStatusSection.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
// Display-only thank-you / failure pages: payment state is verified
|
||||
// server-side via Stripe webhooks (see comfy-api). These pages exist
|
||||
// solely as the redirect target for Stripe Checkout.
|
||||
|
||||
type Status = 'success' | 'failed'
|
||||
|
||||
const { status, locale = 'en' } = defineProps<{
|
||||
status: Status
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const primaryHref =
|
||||
status === 'success' ? externalLinks.cloud : externalLinks.support
|
||||
const secondaryHref =
|
||||
status === 'success'
|
||||
? externalLinks.platformUsage
|
||||
: externalLinks.docsSubscription
|
||||
|
||||
const iconRingClass =
|
||||
status === 'success'
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'border-secondary-mauve text-secondary-mauve'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex min-h-[calc(100dvh-12rem)] items-center justify-center px-6 py-16 lg:py-24"
|
||||
>
|
||||
<div class="flex max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-20 items-center justify-center rounded-full border-2',
|
||||
iconRingClass
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
v-if="status === 'success'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M6 6l12 12" />
|
||||
<path d="M18 6L6 18" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<SectionLabel>{{ t(`payment.${status}.label`, locale) }}</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
|
||||
>
|
||||
{{ t(`payment.${status}.title`, locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas/80 max-w-xl text-base font-light lg:text-lg"
|
||||
>
|
||||
{{ t(`payment.${status}.subtitle`, locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-2 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center"
|
||||
>
|
||||
<BrandButton :href="primaryHref" variant="solid" size="nav">
|
||||
{{ t(`payment.${status}.primaryCta`, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton :href="secondaryHref" variant="outline" size="nav">
|
||||
{{ t(`payment.${status}.secondaryCta`, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
|
||||
@@ -35,20 +35,20 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden pt-16 lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pt-[min(8vw,10rem)] lg:pb-[min(8vw,10rem)]"
|
||||
>
|
||||
<!-- Illustration (overlaps text slightly; stacks above on mobile, right on lg) -->
|
||||
<div
|
||||
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:translate-y-[40px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="block size-full"
|
||||
class="block size-full overflow-visible"
|
||||
viewBox="0 0 1600 1046"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g clip-path="url(#enterpriseHeroClip)">
|
||||
<g>
|
||||
<rect width="1600" height="1046" fill="#211927" />
|
||||
<rect
|
||||
width="800"
|
||||
@@ -84,7 +84,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g class="block-cluster">
|
||||
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
@@ -353,7 +353,7 @@ onMounted(() => {
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
<clipPath id="enterpriseHeroClip">
|
||||
<clipPath id="enterpriseHeroBlockClip">
|
||||
<rect width="1600" height="1046" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@@ -28,7 +28,11 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
<!-- CTA buttons -->
|
||||
<div class="mt-10 flex flex-col gap-4 lg:flex-row">
|
||||
<DownloadLocalButton :locale />
|
||||
<BrandButton :href="externalLinks.github" variant="outline" size="lg">
|
||||
<BrandButton
|
||||
:href="externalLinks.githubInstall"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i
|
||||
class="icon-mask size-5 -translate-y-px mask-[url('/icons/social/github.svg')]"
|
||||
|
||||
@@ -323,7 +323,7 @@ onUnmounted(() => {
|
||||
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
|
||||
<DownloadLocalButton :locale class="lg:min-w-60 lg:p-4" />
|
||||
<BrandButton
|
||||
:href="externalLinks.github"
|
||||
:href="externalLinks.githubInstall"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="lg:min-w-60 lg:p-4"
|
||||
|
||||
@@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
|
||||
<section
|
||||
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
|
||||
>
|
||||
|
||||
328
apps/website/src/composables/useHeroLogo.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
|
||||
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
const IMAGE_COUNT = 16
|
||||
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
|
||||
|
||||
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
|
||||
|
||||
interface HeroLogoConfig {
|
||||
speed: number
|
||||
tiltX: number
|
||||
tiltZ: number
|
||||
zoom: number
|
||||
fov: number
|
||||
logoColor: string
|
||||
extrudeDepth: number
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
speed: 1,
|
||||
tiltX: -0.1,
|
||||
tiltZ: -0.1,
|
||||
zoom: 7,
|
||||
fov: 50,
|
||||
logoColor: '#F2FF59',
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
|
||||
}
|
||||
|
||||
export function useHeroLogo(
|
||||
containerRef: Ref<HTMLElement | undefined>,
|
||||
config: Partial<HeroLogoConfig> = {}
|
||||
) {
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
const loaded = ref(false)
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
stencil: true,
|
||||
alpha: true
|
||||
})
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.style.position = 'absolute'
|
||||
renderer.domElement.style.inset = '0'
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
let disposed = false
|
||||
const teardowns: Array<() => void> = []
|
||||
cleanup = () => {
|
||||
disposed = true
|
||||
teardowns.forEach((fn) => fn())
|
||||
}
|
||||
teardowns.push(() => {
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
})
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
cfg.fov,
|
||||
width / height,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
// Background plane (stencil read)
|
||||
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
|
||||
const bgPlaneMat = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
map: textures[0] ?? null,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
stencilWrite: true,
|
||||
stencilFunc: THREE.EqualStencilFunc,
|
||||
stencilRef: 1,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
stencilZPass: THREE.KeepStencilOp
|
||||
})
|
||||
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
|
||||
bgPlane.renderOrder = 1
|
||||
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
|
||||
scene.add(bgPlane)
|
||||
|
||||
// Logo group
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
|
||||
const s = scaleFactor
|
||||
const depth = cfg.extrudeDepth
|
||||
|
||||
// Front face
|
||||
const shapeGeo = new THREE.ShapeGeometry(shapes)
|
||||
shapeGeo.translate(-cx, -cy, 0)
|
||||
shapeGeo.scale(s, -s, s)
|
||||
const shapeMat = new THREE.MeshBasicMaterial({
|
||||
color: cfg.logoColor,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true
|
||||
})
|
||||
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
|
||||
logoMesh.renderOrder = 2
|
||||
group.add(logoMesh)
|
||||
|
||||
// Extrusion stencil mask
|
||||
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
|
||||
depth,
|
||||
bevelEnabled: false
|
||||
})
|
||||
extrudeGeo.translate(-cx, -cy, -depth)
|
||||
extrudeGeo.scale(s, -s, s)
|
||||
const extrudeMat = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
stencilWrite: true,
|
||||
stencilRef: 1,
|
||||
stencilFunc: THREE.AlwaysStencilFunc,
|
||||
stencilZPass: THREE.ReplaceStencilOp,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
|
||||
extrudeMesh.renderOrder = 0
|
||||
group.add(extrudeMesh)
|
||||
|
||||
// Interaction
|
||||
let isDragging = false
|
||||
let previousX = 0
|
||||
let dragVelocity = 0
|
||||
let currentTiltX = 0
|
||||
let currentTiltY = 0
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let rotationT = 0
|
||||
let currentSlide = 0
|
||||
let slideTimer = 0
|
||||
let animationId = 0
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
pointerX = (e.clientX / window.innerWidth) * 2 - 1
|
||||
pointerY = (e.clientY / window.innerHeight) * 2 - 1
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
isDragging = true
|
||||
dragVelocity = 0
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return
|
||||
dragVelocity = (e.clientX - previousX) * 0.005
|
||||
rotationT += dragVelocity
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
camera.aspect = rect.width / rect.height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(rect.width, rect.height)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
|
||||
function animate() {
|
||||
if (disposed) return
|
||||
animationId = requestAnimationFrame(animate)
|
||||
const dt = clock.getDelta()
|
||||
|
||||
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
|
||||
dragVelocity *= 0.95
|
||||
rotationT += dragVelocity
|
||||
} else if (!isDragging) {
|
||||
dragVelocity = 0
|
||||
}
|
||||
|
||||
rotationT += cfg.speed * dt
|
||||
|
||||
currentTiltX += (pointerY - currentTiltX) * 0.08
|
||||
currentTiltY += (pointerX - currentTiltY) * 0.08
|
||||
|
||||
group.rotation.y = rotationT % (Math.PI * 2)
|
||||
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
|
||||
group.rotation.z = cfg.tiltZ
|
||||
|
||||
if (textures.length > 1) {
|
||||
slideTimer += dt
|
||||
if (slideTimer >= cfg.slideDuration) {
|
||||
slideTimer = 0
|
||||
currentSlide = (currentSlide + 1) % textures.length
|
||||
bgPlaneMat.map = textures[currentSlide]
|
||||
bgPlaneMat.needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
teardowns.push(
|
||||
() => cancelAnimationFrame(animationId),
|
||||
() => window.removeEventListener('mousemove', onMouseMove),
|
||||
() =>
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
|
||||
() => window.removeEventListener('pointermove', onPointerMove),
|
||||
() => window.removeEventListener('pointerup', onPointerUp),
|
||||
() => window.removeEventListener('resize', onResize),
|
||||
() => bgPlaneGeo.dispose(),
|
||||
() => bgPlaneMat.dispose(),
|
||||
() => shapeGeo.dispose(),
|
||||
() => shapeMat.dispose(),
|
||||
() => extrudeGeo.dispose(),
|
||||
() => extrudeMat.dispose(),
|
||||
() => textures.forEach((tex) => tex.dispose())
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[useHeroLogo] initialization failed:', err)
|
||||
cleanup?.()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return { loaded }
|
||||
}
|
||||
68
apps/website/src/config/demos.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface Demo {
|
||||
readonly slug: string
|
||||
readonly arcadeId: string
|
||||
readonly category: TranslationKey
|
||||
readonly title: TranslationKey
|
||||
readonly description: TranslationKey
|
||||
readonly ogImage: string
|
||||
readonly thumbnail: string
|
||||
readonly estimatedTime: TranslationKey
|
||||
readonly durationIso: string
|
||||
readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
readonly tags: readonly string[]
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
{
|
||||
slug: 'image-to-video',
|
||||
arcadeId: 'F3CTalnGnR4R0qJIVMNX',
|
||||
category: 'demos.category.templates',
|
||||
title: 'demos.image-to-video.title',
|
||||
description: 'demos.image-to-video.description',
|
||||
transcript: 'demos.image-to-video.transcript',
|
||||
ogImage: '/images/demos/image-to-video-og.png',
|
||||
thumbnail: '/images/demos/image-to-video-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
arcadeId: 'KhqcXDElnFWklo7ACBqE',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.workflow-templates.title',
|
||||
description: 'demos.workflow-templates.description',
|
||||
transcript: 'demos.workflow-templates.transcript',
|
||||
ogImage: '/images/demos/workflow-templates-og.png',
|
||||
thumbnail: '/images/demos/workflow-templates-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
}
|
||||
]
|
||||
|
||||
export function getDemoBySlug(slug: string): Demo | undefined {
|
||||
return demos.find((demo) => demo.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextDemo(slug: string): Demo {
|
||||
if (demos.length === 0) {
|
||||
throw new Error('No demos configured')
|
||||
}
|
||||
const index = demos.findIndex((demo) => demo.slug === slug)
|
||||
if (index === -1) {
|
||||
throw new Error(`Unknown demo slug: ${slug}`)
|
||||
}
|
||||
return demos[(index + 1) % demos.length]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const baseRoutes = {
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
contact: '/contact'
|
||||
@@ -33,8 +34,11 @@ export const externalLinks = {
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -1119,6 +1119,10 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1143,6 +1147,10 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
@@ -1599,7 +1607,7 @@ const translations = {
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
|
||||
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
|
||||
@@ -3498,18 +3506,6 @@ const translations = {
|
||||
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.label': {
|
||||
en: 'GROOVE JONES CONTRIBUTORS',
|
||||
'zh-CN': 'GROOVE JONES 贡献者'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.name': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.role': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
|
||||
// Contact – FormSection
|
||||
'contact.form.badge': {
|
||||
@@ -3542,6 +3538,80 @@ const translations = {
|
||||
'zh-CN': '我们会为您处理请求。'
|
||||
},
|
||||
|
||||
'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
|
||||
'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
|
||||
|
||||
'demos.image-to-video.title': {
|
||||
en: 'Create a Video from an Image',
|
||||
'zh-CN': '从图片创建视频'
|
||||
},
|
||||
'demos.image-to-video.description': {
|
||||
en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
|
||||
'zh-CN':
|
||||
'了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
|
||||
},
|
||||
'demos.image-to-video.transcript': {
|
||||
en: '<ol><li><strong>Open ComfyUI</strong> — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.</li><li><strong>Browse templates</strong> — Click the workflow templates button in the sidebar to browse available starting points.</li><li><strong>Select Image to Video</strong> — Find and select the "Image to Video" template from the list to load it onto your canvas.</li><li><strong>Upload your image</strong> — Click the image upload node and select the source image you want to animate.</li><li><strong>Run the workflow</strong> — Click the "Queue" button to execute the workflow and generate your video output.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开 ComfyUI</strong> — 启动应用程序,您将看到基于节点的工作流画布。</li><li><strong>浏览模板</strong> — 点击侧栏中的工作流模板按钮,浏览可用模板。</li><li><strong>选择图片转视频</strong> — 从列表中找到并选择"图片转视频"模板。</li><li><strong>上传图片</strong> — 点击图片上传节点,选择要动画化的源图片。</li><li><strong>运行工作流</strong> — 点击"排队"按钮执行工作流并生成视频输出。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.workflow-templates.title': {
|
||||
en: 'Browse Workflow Templates',
|
||||
'zh-CN': '浏览工作流模板'
|
||||
},
|
||||
'demos.workflow-templates.description': {
|
||||
en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
|
||||
'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
|
||||
},
|
||||
'demos.workflow-templates.transcript': {
|
||||
en: '<ol><li><strong>Open the template browser</strong> — Click the templates icon in the ComfyUI sidebar to open the template library.</li><li><strong>Browse categories</strong> — Templates are organized by task: image generation, video, upscaling, and more.</li><li><strong>Preview a template</strong> — Hover over any template to see a preview of its workflow and expected output.</li><li><strong>Load and customize</strong> — Click to load a template, then modify parameters to fit your needs.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
|
||||
'demos.transcript.note': {
|
||||
en: '(for accessibility & search)',
|
||||
'zh-CN': '(无障碍和搜索)'
|
||||
},
|
||||
'demos.loading': {
|
||||
en: 'Loading interactive demo…',
|
||||
'zh-CN': '正在加载互动演示…'
|
||||
},
|
||||
'demos.noscript': {
|
||||
en: 'This interactive demo requires JavaScript.',
|
||||
'zh-CN': '此互动演示需要 JavaScript。'
|
||||
},
|
||||
'demos.noscript.link': {
|
||||
en: 'View on Arcade →',
|
||||
'zh-CN': '在 Arcade 上查看 →'
|
||||
},
|
||||
'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
|
||||
'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
|
||||
'demos.difficulty.intermediate': {
|
||||
en: 'Intermediate',
|
||||
'zh-CN': '中级'
|
||||
},
|
||||
'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
|
||||
'demos.embed.label': {
|
||||
en: 'Interactive demo',
|
||||
'zh-CN': '互动演示'
|
||||
},
|
||||
'demos.comingSoon.title': {
|
||||
en: 'Coming Soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
'demos.comingSoon.body': {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
en: "What's next?",
|
||||
'zh-CN': '接下来看什么?'
|
||||
@@ -3592,6 +3662,49 @@ const translations = {
|
||||
'customers.feedback.role3': {
|
||||
en: 'Head of AI at Creative Studios',
|
||||
'zh-CN': 'Creative Studios AI 负责人'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.success.title': {
|
||||
en: 'Payment successful',
|
||||
'zh-CN': '支付成功'
|
||||
},
|
||||
'payment.success.subtitle': {
|
||||
en: "Thanks for your purchase. Your account has been credited and you're ready to keep building.",
|
||||
'zh-CN': '感谢您的购买。您的账户已充值完成,可以继续创作了。'
|
||||
},
|
||||
'payment.success.primaryCta': {
|
||||
en: 'CONTINUE TO COMFY CLOUD',
|
||||
'zh-CN': '前往 COMFY CLOUD'
|
||||
},
|
||||
'payment.success.secondaryCta': {
|
||||
en: 'VIEW USAGE & PAYMENTS',
|
||||
'zh-CN': '查看用量与支付'
|
||||
},
|
||||
'payment.failed.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.failed.title': {
|
||||
en: 'Unable to complete payment',
|
||||
'zh-CN': '无法完成支付'
|
||||
},
|
||||
'payment.failed.subtitle': {
|
||||
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
|
||||
'zh-CN':
|
||||
'您的支付未能完成,未发生扣款。如需帮助,请联系支持或查阅订阅文档。'
|
||||
},
|
||||
'payment.failed.primaryCta': {
|
||||
en: 'CONTACT SUPPORT',
|
||||
'zh-CN': '联系支持'
|
||||
},
|
||||
'payment.failed.secondaryCta': {
|
||||
en: 'READ SUBSCRIPTION DOCS',
|
||||
'zh-CN': '查看订阅文档'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
}
|
||||
@@ -17,10 +18,13 @@ interface Props {
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
@@ -62,6 +66,7 @@ const websiteJsonLd = {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
@@ -109,6 +114,7 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
|
||||
@@ -7,9 +7,14 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
|
||||
import PricingSection from '../../components/product/cloud/PricingSection.vue'
|
||||
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/cloud/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy Cloud — AI in the Cloud">
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — AI in the Cloud"
|
||||
description={t('cloud.hero.subtitle', 'en')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
|
||||
>
|
||||
<HeroSection />
|
||||
<ReasonSection />
|
||||
<AIModelsSection />
|
||||
|
||||
139
apps/website/src/pages/demos/[slug].astro
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
|
||||
import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
<DemoHeroSection
|
||||
label={t(demo.category)}
|
||||
title={title}
|
||||
description={description}
|
||||
difficulty={demo.difficulty}
|
||||
estimatedTime={demo.estimatedTime}
|
||||
/>
|
||||
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
client:load
|
||||
/>
|
||||
|
||||
{demo.transcript && (
|
||||
<DemoTranscript
|
||||
transcript={t(demo.transcript)}
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<DemoNavSection
|
||||
nextTitle={t(nextDemo.title)}
|
||||
nextSlug={nextDemo.slug}
|
||||
nextThumbnail={nextDemo.thumbnail}
|
||||
/>
|
||||
</BaseLayout>
|
||||
8
apps/website/src/pages/demos/index.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI.">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
@@ -7,9 +7,14 @@ import ReasonSection from '../components/product/local/ReasonSection.vue'
|
||||
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Download Comfy — Run AI Locally">
|
||||
<BaseLayout
|
||||
title="Download Comfy — Run AI Locally"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
<ReasonSection />
|
||||
|
||||
@@ -8,9 +8,14 @@ import UseCaseSection from '../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
|
||||
import GetStartedSection from '../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t('hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection client:load />
|
||||
|
||||
12
apps/website/src/pages/payment/failed.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Payment Failed — Comfy"
|
||||
description="Your payment was not completed."
|
||||
noindex
|
||||
>
|
||||
<PaymentStatusSection status="failed" />
|
||||
</BaseLayout>
|
||||
12
apps/website/src/pages/payment/success.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Payment Successful — Comfy"
|
||||
description="Your payment was processed successfully."
|
||||
noindex
|
||||
>
|
||||
<PaymentStatusSection status="success" />
|
||||
</BaseLayout>
|
||||
@@ -7,9 +7,14 @@ import AudienceSection from '../../../components/product/cloud/AudienceSection.v
|
||||
import PricingSection from '../../../components/product/cloud/PricingSection.vue'
|
||||
import ProductCardsSection from '../../../components/product/cloud/ProductCardsSection.vue'
|
||||
import FAQSection from '../../../components/product/cloud/FAQSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy Cloud — 云端 AI">
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — 云端 AI"
|
||||
description={t('cloud.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
<AIModelsSection locale="zh-CN" />
|
||||
|
||||
143
apps/website/src/pages/zh-CN/demos/[slug].astro
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
|
||||
import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
<DemoHeroSection
|
||||
label={t(demo.category, 'zh-CN')}
|
||||
title={title}
|
||||
description={description}
|
||||
difficulty={demo.difficulty}
|
||||
estimatedTime={demo.estimatedTime}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
{demo.transcript && (
|
||||
<DemoTranscript
|
||||
transcript={t(demo.transcript, 'zh-CN')}
|
||||
locale="zh-CN"
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<DemoNavSection
|
||||
nextTitle={t(nextDemo.title, 'zh-CN')}
|
||||
nextSlug={nextDemo.slug}
|
||||
nextThumbnail={nextDemo.thumbnail}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
</BaseLayout>
|
||||
17
apps/website/src/pages/zh-CN/demos/index.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。">
|
||||
<section class="flex min-h-[60vh] items-center justify-center px-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light">
|
||||
{t('demos.comingSoon.title', 'zh-CN')}
|
||||
</h1>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{t('demos.comingSoon.body', 'zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -7,9 +7,14 @@ import ReasonSection from '../../components/product/local/ReasonSection.vue'
|
||||
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="下载 — Comfy">
|
||||
<BaseLayout
|
||||
title="下载 — Comfy"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
|
||||
@@ -8,9 +8,14 @@ import UseCaseSection from '../../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
8
apps/website/src/pages/zh-CN/payment/failed.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="支付失败 — Comfy" description="您的支付未能完成。" noindex>
|
||||
<PaymentStatusSection status="failed" locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
8
apps/website/src/pages/zh-CN/payment/success.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="支付成功 — Comfy" description="您的支付已成功完成。" noindex>
|
||||
<PaymentStatusSection status="success" locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"id": "14af6003-d4ee-4dee-8e3d-cbff2e5519b3",
|
||||
"revision": 0,
|
||||
"last_node_id": 205,
|
||||
"last_link_id": 383,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 205,
|
||||
"type": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
|
||||
"pos": [4720, 5820],
|
||||
"size": [400, 470],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "lotus_model",
|
||||
"name": "unet_name_1",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name_1"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["76", "unet_name"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 8,
|
||||
"lastNodeId": 205,
|
||||
"lastLinkId": 383,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Depth to Image (Z-Image-Turbo)",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [28, 4936, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1599, 4936, 128, 68]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "80e6915f-5d59-4d6b-a197-d8c565ad2922",
|
||||
"name": "unet_name_1",
|
||||
"type": "COMBO",
|
||||
"linkIds": [258],
|
||||
"pos": [132, 4960]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "47f9a22d-6619-4917-9447-a7d5d08dceb5",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [],
|
||||
"pos": [1623, 4960]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 76,
|
||||
"type": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
|
||||
"pos": [250, 4910],
|
||||
"size": [400, 210],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name"
|
||||
},
|
||||
"link": 258
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["203", "unet_name"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 258,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 76,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG",
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-30, -4760]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 8,
|
||||
"lastNodeId": 205,
|
||||
"lastLinkId": 383,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Image to Depth Map (Lotus)",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-60, -173, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1650, -173, 128, 68]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "d721b249-fd2a-441b-9a78-2805f04e2644",
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [256],
|
||||
"pos": [44, -149]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "2ec278bd-0b66-4b30-9c5b-994d5f638214",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [],
|
||||
"pos": [1674, -149]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 203,
|
||||
"type": "UNETLoader",
|
||||
"pos": [180, -200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name"
|
||||
},
|
||||
"link": 256
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["lotus-depth-d-v1-1.safetensors", "default"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 256,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 203,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG",
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [40, 350]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG",
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-4500, -5670]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
68
browser_tests/assets/missing/node_replacement_multi.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "E2E_OldUpscaler",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "image", "type": "IMAGE", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldUpscaler" },
|
||||
"widgets_values": ["lanczos", 1.5]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SaveImage",
|
||||
"pos": [900, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 2 }],
|
||||
"properties": { "Node name for S&R": "SaveImage" },
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [[2, 2, 0, 3, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
59
browser_tests/assets/missing/node_replacement_simple.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEDecode",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 1 },
|
||||
{ "name": "vae", "type": "VAE", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "VAEDecode" },
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
1449
browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [225, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [270, 108],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [270, 104],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [225, 104],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -352,6 +352,12 @@ export class ComfyPage {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async idleFrames(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
}
|
||||
@@ -460,10 +466,15 @@ export const testComfySnapToGridGridSize = 50
|
||||
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
initialFeatureFlags: Record<string, unknown>
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
comfyFiles: ComfyFiles
|
||||
}>({
|
||||
// Allows configuring feature flags for tests with before initial setup:
|
||||
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
|
||||
initialFeatureFlags: [{}, { option: true }],
|
||||
|
||||
page: async ({ page, browserName }, use) => {
|
||||
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
|
||||
return use(page)
|
||||
@@ -480,7 +491,7 @@ export const comfyPageFixture = base.extend<{
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async ({ page, request }, use, testInfo) => {
|
||||
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = testInfo
|
||||
@@ -505,6 +516,7 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
@@ -523,6 +535,10 @@ export const comfyPageFixture = base.extend<{
|
||||
await comfyPage.cloudAuth.mockAuth()
|
||||
}
|
||||
|
||||
if (Object.keys(initialFeatureFlags).length > 0) {
|
||||
await comfyPage.featureFlags.seedFlags(initialFeatureFlags)
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
if (isVueNodes) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
/**
|
||||
@@ -37,6 +38,22 @@ export class VueNodeHelpers {
|
||||
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
getInputSlotRow(nodeId: string, slotIndex: number): Locator {
|
||||
return this.getNodeLocator(nodeId)
|
||||
.locator('.lg-slot--input')
|
||||
.filter({
|
||||
has: this.page.locator(
|
||||
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
getInputSlotConnectionDot(nodeId: string, slotIndex: number): Locator {
|
||||
return this.getInputSlotRow(nodeId, slotIndex).getByTestId(
|
||||
TestIds.node.slotConnectionDot
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
const ASSETS_ROUTE_PATTERN = /\/api\/assets(?:\?.*)?$/
|
||||
const cloudAssetRequestsByPage = new WeakMap<Page, string[]>()
|
||||
|
||||
function makeAssetsResponse(assets: ReadonlyArray<Asset>): ListAssetsResponse {
|
||||
return { assets: [...assets], total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
export function assetRequestIncludesTag(url: string, tag: string): boolean {
|
||||
const includeTags = new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.includes(tag)
|
||||
}
|
||||
|
||||
export function countAssetRequestsByTag(
|
||||
requests: string[],
|
||||
tag: string
|
||||
): number {
|
||||
return requests.filter((url) => assetRequestIncludesTag(url, tag)).length
|
||||
}
|
||||
|
||||
export const assetApiFixture = base.extend<{
|
||||
assetApi: AssetHelper
|
||||
}>({
|
||||
@@ -14,3 +40,31 @@ export const assetApiFixture = base.extend<{
|
||||
await assetApi.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
export function createCloudAssetsFixture(assets: ReadonlyArray<Asset>) {
|
||||
return comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
const cloudAssetRequests: string[] = []
|
||||
cloudAssetRequestsByPage.set(page, cloudAssetRequests)
|
||||
|
||||
async function assetsRouteHandler(route: Route) {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(assets))
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
|
||||
cloudAssetRequestsByPage.delete(page)
|
||||
},
|
||||
cloudAssetRequests: async ({ page }, use) => {
|
||||
await use(cloudAssetRequestsByPage.get(page) ?? [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,10 +39,32 @@ class ComfyQueueButton {
|
||||
await this.dropdownButton.click()
|
||||
return new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
}
|
||||
|
||||
public async openOptions() {
|
||||
const options = new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
if (!(await options.menu.isVisible())) {
|
||||
await this.dropdownButton.click()
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly menu: Locator
|
||||
public readonly modeItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menu = page.getByRole('menu')
|
||||
this.modeItems = this.menu.getByRole('menuitem')
|
||||
}
|
||||
|
||||
public modeItem(name: string) {
|
||||
return this.menu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
public async selectMode(name: string) {
|
||||
await this.modeItem(name).click()
|
||||
}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
private readonly menuTrigger: Locator
|
||||
readonly newWorkflowButton: Locator
|
||||
readonly workflowTabs: Locator
|
||||
readonly integratedTabBarActions: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menuLocator = page.locator('.comfy-command-menu')
|
||||
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
||||
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
|
||||
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
|
||||
this.integratedTabBarActions = this.workflowTabs.getByTestId(
|
||||
TestIds.topbar.integratedTabBarActions
|
||||
)
|
||||
}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
|
||||
47
browser_tests/fixtures/data/nodeReplacements.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
|
||||
|
||||
/**
|
||||
* Mock node replacement mappings for e2e tests.
|
||||
*
|
||||
* Maps fake "missing" node types (E2E_OldSampler, E2E_OldUpscaler) to real
|
||||
* core node types that are always available in the test server.
|
||||
*/
|
||||
export const mockNodeReplacements: NodeReplacementResponse = {
|
||||
E2E_OldSampler: [
|
||||
{
|
||||
new_node_id: 'KSampler',
|
||||
old_node_id: 'E2E_OldSampler',
|
||||
old_widget_ids: ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'],
|
||||
input_mapping: [
|
||||
{ new_id: 'model', old_id: 'model' },
|
||||
{ new_id: 'positive', old_id: 'positive' },
|
||||
{ new_id: 'negative', old_id: 'negative' },
|
||||
{ new_id: 'latent_image', old_id: 'latent_image' },
|
||||
{ new_id: 'seed', old_id: 'seed' },
|
||||
{ new_id: 'steps', old_id: 'steps' },
|
||||
{ new_id: 'cfg', old_id: 'cfg' },
|
||||
{ new_id: 'sampler_name', old_id: 'sampler_name' },
|
||||
{ new_id: 'scheduler', old_id: 'scheduler' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
}
|
||||
],
|
||||
E2E_OldUpscaler: [
|
||||
{
|
||||
new_node_id: 'ImageScaleBy',
|
||||
old_node_id: 'E2E_OldUpscaler',
|
||||
old_widget_ids: ['upscale_method', 'scale_by'],
|
||||
input_mapping: [
|
||||
{ new_id: 'image', old_id: 'image' },
|
||||
{ new_id: 'upscale_method', old_id: 'upscale_method' },
|
||||
{ new_id: 'scale_by', old_id: 'scale_by' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** Subset containing only the E2E_OldSampler replacement. */
|
||||
export const mockNodeReplacementsSingle: NodeReplacementResponse = {
|
||||
E2E_OldSampler: mockNodeReplacements.E2E_OldSampler
|
||||
}
|
||||
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
136
browser_tests/fixtures/helpers/MaskEditorHelper.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
const RGB_CANVAS_INDEX = 1
|
||||
|
||||
export type BrushSliderLabel = 'thickness'
|
||||
|
||||
export class MaskEditorHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
|
||||
private get page() {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
async loadImageOnNode() {
|
||||
await this.comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await this.comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = this.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
async openDialog(): Promise<Locator> {
|
||||
const { imagePreview } = await this.loadImageOnNode()
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await this.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = this.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async drawStrokeOnPointerZone(dialog: Locator) {
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await this.page.mouse.move(startX, startY)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(endX, endY, { steps: 10 })
|
||||
await this.page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
async drawStrokeAndExpectPixels(dialog: Locator) {
|
||||
await this.drawStrokeOnPointerZone(dialog)
|
||||
await expect.poll(() => this.pollMaskPixelCount()).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
getCanvasPixelData(canvasIndex: number) {
|
||||
return this.page.evaluate((idx) => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
const canvas = canvases[idx] as HTMLCanvasElement | undefined
|
||||
if (!canvas) return null
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
}, canvasIndex)
|
||||
}
|
||||
|
||||
pollMaskPixelCount(): Promise<number> {
|
||||
return this.getCanvasPixelData(MASK_CANVAS_INDEX).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
pollRgbPixelCount(): Promise<number> {
|
||||
return this.getCanvasPixelData(RGB_CANVAS_INDEX).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
getCanvasSnapshot(canvasIndex: number): Promise<string> {
|
||||
return this.page.evaluate((idx) => {
|
||||
const canvas = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)[idx] as HTMLCanvasElement | undefined
|
||||
return canvas?.toDataURL() ?? ''
|
||||
}, canvasIndex)
|
||||
}
|
||||
|
||||
brushInput(dialog: Locator, label: BrushSliderLabel): Locator {
|
||||
return dialog.getByTestId(`brush-${label}-input`)
|
||||
}
|
||||
}
|
||||
|
||||
export const maskEditorTest = comfyPageFixture.extend<{
|
||||
maskEditor: MaskEditorHelper
|
||||
}>({
|
||||
maskEditor: async ({ comfyPage }, use) => {
|
||||
await use(new MaskEditorHelper(comfyPage))
|
||||
}
|
||||
})
|
||||
93
browser_tests/fixtures/helpers/NodeReplacementHelper.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
|
||||
|
||||
/**
|
||||
* Mock `/api/node_replacements` and enable the node replacement feature.
|
||||
*
|
||||
* Unlike features that only consult settings (e.g. shareWorkflowDialog,
|
||||
* managerDialog), node replacement gates on `api.serverFeatureFlags`. The
|
||||
* server sends a `feature_flags` WS message that wholesale replaces
|
||||
* `serverFeatureFlags`, racing with any test-side override done via
|
||||
* `page.evaluate`. To make the flow deterministic across CI shards, this
|
||||
* helper patches `WebSocket.prototype` so every incoming `feature_flags`
|
||||
* message has `node_replacements: true` injected before the api's WS
|
||||
* handler sees it. Reload the page so the patched WebSocket and persisted
|
||||
* settings apply to a fresh app boot, then wait for the resulting
|
||||
* `/api/node_replacements` fetch before returning.
|
||||
*/
|
||||
export async function setupNodeReplacement(
|
||||
comfyPage: ComfyPage,
|
||||
replacements: NodeReplacementResponse
|
||||
): Promise<void> {
|
||||
await comfyPage.page.route('**/api/node_replacements', (route) =>
|
||||
route.fulfill({ json: replacements })
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
|
||||
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
const proto = window.WebSocket.prototype
|
||||
const originalAdd = proto.addEventListener
|
||||
proto.addEventListener = function patchedAdd(
|
||||
this: WebSocket,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
const wrapped = function (this: WebSocket, event: Event) {
|
||||
const msgEvent = event as MessageEvent
|
||||
if (typeof msgEvent.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(msgEvent.data)
|
||||
if (
|
||||
msg &&
|
||||
msg.type === 'feature_flags' &&
|
||||
msg.data &&
|
||||
typeof msg.data === 'object'
|
||||
) {
|
||||
msg.data.node_replacements = true
|
||||
const patched = new MessageEvent('message', {
|
||||
data: JSON.stringify(msg),
|
||||
origin: msgEvent.origin,
|
||||
lastEventId: msgEvent.lastEventId
|
||||
})
|
||||
return (listener as EventListener).call(this, patched)
|
||||
}
|
||||
} catch {
|
||||
// not JSON or not a feature_flags message - pass through
|
||||
}
|
||||
}
|
||||
return (listener as EventListener).call(this, event)
|
||||
}
|
||||
return originalAdd.call(this, type, wrapped as EventListener, options)
|
||||
}
|
||||
return originalAdd.call(
|
||||
this,
|
||||
type,
|
||||
listener as EventListenerOrEventListenerObject,
|
||||
options
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchPromise = comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/node_replacements') && response.ok(),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
await fetchPromise
|
||||
}
|
||||
|
||||
export function getSwapNodesGroup(page: Page): Locator {
|
||||
return page.getByTestId(TestIds.dialogs.swapNodesGroup)
|
||||
}
|
||||
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||
@@ -64,6 +64,7 @@ export const TestIds = {
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
@@ -90,6 +91,8 @@ export const TestIds = {
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
@@ -113,7 +116,8 @@ export const TestIds = {
|
||||
titleInput: 'node-title-input',
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image'
|
||||
mainImage: 'main-image',
|
||||
slotConnectionDot: 'slot-connection-dot'
|
||||
},
|
||||
selectionToolbox: {
|
||||
root: 'selection-toolbox',
|
||||
@@ -210,6 +214,7 @@ export const TestIds = {
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
jobDetailsPopover: 'queue-job-details-popover',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list',
|
||||
notificationBanner: 'queue-notification-banner'
|
||||
|
||||
34
browser_tests/fixtures/utils/groupHelpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const GROUP_TITLE_CLICK_OFFSET_X = 50
|
||||
const GROUP_TITLE_CLICK_OFFSET_Y = 15
|
||||
|
||||
/**
|
||||
* Returns the client-space position of a group's title bar (for clicking).
|
||||
*/
|
||||
export async function getGroupTitlePosition(
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pos = await comfyPage.page.evaluate(
|
||||
({ title, offsetX, offsetY }) => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups.find(
|
||||
(g: { title: string }) => g.title === title
|
||||
)
|
||||
if (!group) return null
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + offsetX,
|
||||
group.pos[1] + offsetY
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
},
|
||||
{
|
||||
title,
|
||||
offsetX: GROUP_TITLE_CLICK_OFFSET_X,
|
||||
offsetY: GROUP_TITLE_CLICK_OFFSET_Y
|
||||
}
|
||||
)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
52
browser_tests/fixtures/utils/jobFixtures.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobRecords(
|
||||
listItems: readonly JobEntry[]
|
||||
): MockJobRecord[] {
|
||||
return listItems.map(createMockJobRecord)
|
||||
}
|
||||
20
browser_tests/fixtures/utils/selectionToolbox.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Opens the selection toolbox "More Options" menu and returns the menu
|
||||
* locator so callers can scope follow-up queries to it.
|
||||
*/
|
||||
export async function openMoreOptions(comfyPage: ComfyPage): Promise<Locator> {
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
@@ -1,51 +1,20 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
|
||||
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const pattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
}
|
||||
await page.route(pattern, assetsRouteHandler)
|
||||
await use()
|
||||
await page.unroute(pattern, assetsRouteHandler)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
@@ -62,11 +31,9 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
// new nodes resolve against the cloud asset list after the fetch.
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
@@ -12,15 +13,7 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
|
||||
|
||||
const titlePos = await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups[0]
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
})
|
||||
const titlePos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.canvas.click({ position: titlePos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -2,29 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Returns the client-space position of a group's title bar (for clicking).
|
||||
*/
|
||||
async function getGroupTitlePosition(
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pos = await comfyPage.page.evaluate((title) => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups.find(
|
||||
(g: { title: string }) => g.title === title
|
||||
)
|
||||
if (!group) return null
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
/**
|
||||
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
548
browser_tests/tests/keybindingPanel.spec.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
|
||||
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
|
||||
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
|
||||
|
||||
async function searchKeybindings(page: Page, query: string) {
|
||||
await getKeybindingSearchInput(page).fill(query)
|
||||
}
|
||||
|
||||
async function clearSearch(page: Page) {
|
||||
await getKeybindingSearchInput(page).clear()
|
||||
}
|
||||
|
||||
function getKeybindingSearchInput(page: Page): Locator {
|
||||
return page.getByPlaceholder('Search Keybindings...')
|
||||
}
|
||||
|
||||
function getCommandRow(page: Page, commandId: string): Locator {
|
||||
return page
|
||||
.locator('.keybinding-panel tr')
|
||||
.filter({ has: page.locator(`[title="${commandId}"]`) })
|
||||
}
|
||||
|
||||
function getExpansionContent(page: Page, commandId: string): Locator {
|
||||
// PrimeVue renders the expansion row as the next sibling <tr> of the
|
||||
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
|
||||
return getCommandRow(page, commandId)
|
||||
.locator('xpath=following-sibling::tr[1]')
|
||||
.getByTestId('keybinding-expansion-content')
|
||||
}
|
||||
|
||||
async function openContextMenu(page: Page, commandId: string) {
|
||||
const row = getCommandRow(page, commandId)
|
||||
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: /Change keybinding/i })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
function getKeybindingInput(page: Page): Locator {
|
||||
return getEditKeybindingDialog(page).locator('input[autofocus]')
|
||||
}
|
||||
|
||||
function getEditKeybindingDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Modify keybinding/i })
|
||||
}
|
||||
|
||||
function getRemoveAllKeybindingsDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Remove all keybindings/i })
|
||||
}
|
||||
|
||||
function getResetAllKeybindingsDialog(page: Page): Locator {
|
||||
return page.getByRole('dialog', { name: /Reset all keybindings/i })
|
||||
}
|
||||
|
||||
async function pressComboOnInput(page: Page, combo: string) {
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeFocused()
|
||||
await input.press(combo)
|
||||
}
|
||||
|
||||
async function saveAndCloseKeybindingDialog(page: Page) {
|
||||
const dialog = getEditKeybindingDialog(page)
|
||||
await dialog.getByRole('button', { name: /Save/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
async function cancelAndCloseDialog(page: Page) {
|
||||
const dialog = getEditKeybindingDialog(page)
|
||||
await dialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
|
||||
await row.getByRole('button', { name: /Add new keybinding/i }).click()
|
||||
await pressComboOnInput(page, combo)
|
||||
await saveAndCloseKeybindingDialog(page)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await registerNoBindingCommand(comfyPage)
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.category('Keybinding').click()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
|
||||
})
|
||||
|
||||
async function registerNoBindingCommand(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate((commandId) => {
|
||||
const app = window.app!
|
||||
app.registerExtension({
|
||||
name: 'TestExtension.KeybindingPanelE2E',
|
||||
commands: [{ id: commandId, function: () => {} }]
|
||||
})
|
||||
}, NO_BINDING_COMMAND)
|
||||
}
|
||||
|
||||
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
test.describe('Row Expansion', () => {
|
||||
test('Click on row with 2+ keybindings toggles expansion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
|
||||
test('Click on row with 1 keybinding does not expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
|
||||
|
||||
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Double-Click', () => {
|
||||
test('Double-click row with 0 keybindings opens Add dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Double-click row with 1 keybinding opens Edit dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Context Menu', () => {
|
||||
test('Right-click row shows context menu with correct items', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const changeItem = page.getByRole('menuitem', {
|
||||
name: /Change keybinding/i
|
||||
})
|
||||
const addItem = page.getByRole('menuitem', {
|
||||
name: /Add new keybinding/i
|
||||
})
|
||||
const resetItem = page.getByRole('menuitem', {
|
||||
name: /Reset to default/i
|
||||
})
|
||||
const removeItem = page.getByRole('menuitem', {
|
||||
name: /Remove keybinding/i
|
||||
})
|
||||
|
||||
await expect(changeItem).toBeVisible()
|
||||
await expect(addItem).toBeVisible()
|
||||
await expect(resetItem).toBeVisible()
|
||||
await expect(removeItem).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test("Context menu 'Add new keybinding' opens add dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeHidden()
|
||||
|
||||
await openContextMenu(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
|
||||
|
||||
await expect(expansionContent).toBeVisible()
|
||||
})
|
||||
|
||||
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F9')
|
||||
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
|
||||
|
||||
const confirmDialog = getRemoveAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
|
||||
|
||||
await expect(row.locator('td').nth(1)).toContainText('-')
|
||||
})
|
||||
|
||||
test("Context menu 'Reset to default' resets modified command", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F10')
|
||||
|
||||
await openContextMenu(page, SINGLE_BINDING_COMMAND)
|
||||
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
|
||||
|
||||
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Context menu items disabled when no keybindings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
await openContextMenu(page, NO_BINDING_COMMAND)
|
||||
|
||||
const changeItem = page.getByRole('menuitem', {
|
||||
name: /Change keybinding/i
|
||||
})
|
||||
const removeItem = page.getByRole('menuitem', {
|
||||
name: /Remove keybinding/i
|
||||
})
|
||||
|
||||
await expect(changeItem).toHaveAttribute('data-disabled', '')
|
||||
await expect(removeItem).toHaveAttribute('data-disabled', '')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Action Buttons', () => {
|
||||
test('Edit button opens edit dialog for single-binding command', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const editButton = row.getByRole('button', { name: /^Edit$/i })
|
||||
await expect(editButton).toBeVisible()
|
||||
await editButton.click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Add button opens add dialog', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await row.getByRole('button', { name: /Add new keybinding/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Reset button is disabled for unmodified commands', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
const resetButton = row.getByRole('button', { name: /Reset/i })
|
||||
await expect(resetButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F11')
|
||||
|
||||
const resetButton = row.getByRole('button', { name: /Reset/i })
|
||||
await expect(resetButton).toBeEnabled()
|
||||
|
||||
await resetButton.click()
|
||||
|
||||
await expect(resetButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button is disabled for commands with 0 keybindings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await expect(deleteButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button removes single keybinding directly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, NO_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, NO_BINDING_COMMAND)
|
||||
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F12')
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await expect(deleteButton).toBeEnabled()
|
||||
await deleteButton.click()
|
||||
|
||||
await expect(row.locator('td').nth(1)).toContainText('-')
|
||||
})
|
||||
|
||||
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
const deleteButton = row.getByRole('button', { name: /Delete/i })
|
||||
await deleteButton.click()
|
||||
|
||||
const confirmDialog = getRemoveAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
await expect(row.locator('td').nth(1)).not.toContainText('-')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expanded Row Actions', () => {
|
||||
test('Edit button in expanded row opens edit dialog for that binding', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
const firstBindingRow = expansionContent
|
||||
.getByTestId('keybinding-expansion-binding')
|
||||
.first()
|
||||
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
|
||||
|
||||
const input = getKeybindingInput(page)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await cancelAndCloseDialog(page)
|
||||
})
|
||||
|
||||
test('Delete button in expanded row removes that binding and collapses', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
const bindingRows = expansionContent.getByTestId(
|
||||
'keybinding-expansion-binding'
|
||||
)
|
||||
await expect
|
||||
.poll(() => bindingRows.count(), {
|
||||
message: 'Expected at least 2 bindings'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
const initialBindingCount = await bindingRows.count()
|
||||
|
||||
await bindingRows
|
||||
.first()
|
||||
.getByRole('button', { name: /Remove keybinding/i })
|
||||
.click()
|
||||
|
||||
if (initialBindingCount === 2) {
|
||||
// Expansion auto-collapses when bindings drop below 2
|
||||
await expect(expansionContent).toBeHidden()
|
||||
} else {
|
||||
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Reset All', () => {
|
||||
test('Reset All button shows confirmation and resets on confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await addKeybindingToRow(page, row, 'Control+Shift+F8')
|
||||
|
||||
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
|
||||
|
||||
await clearSearch(page)
|
||||
|
||||
const resetAllButton = page
|
||||
.locator('.keybinding-panel')
|
||||
.getByRole('button', { name: /Reset All/i })
|
||||
await resetAllButton.click()
|
||||
|
||||
const confirmDialog = getResetAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
|
||||
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
|
||||
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
|
||||
await expect(
|
||||
rowAfterReset.getByRole('button', { name: /Reset/i })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const resetAllButton = page
|
||||
.locator('.keybinding-panel')
|
||||
.getByRole('button', { name: /Reset All/i })
|
||||
await resetAllButton.click()
|
||||
|
||||
const confirmDialog = getResetAllKeybindingsDialog(page)
|
||||
await expect(confirmDialog).toBeVisible()
|
||||
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
|
||||
|
||||
await expect(confirmDialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search Filter', () => {
|
||||
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
|
||||
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
|
||||
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
|
||||
await expect(expansionContent).toBeVisible()
|
||||
|
||||
// Changing the filter triggers watch(filters, ...) which clears expansion
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
127
browser_tests/tests/layoutSidebarSettings.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
|
||||
test.describe('Comfy.Sidebar.Size', () => {
|
||||
test('"small" applies small-sidebar class', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass('small-sidebar')
|
||||
})
|
||||
|
||||
test('"normal" does not apply small-sidebar class', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'small-sidebar'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Sidebar.Style', () => {
|
||||
// `isConnected` overrides the Style setting when the toolbar overflows;
|
||||
// small (48px) items keep content under the default viewport so Style
|
||||
// actually drives rendering.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
|
||||
})
|
||||
|
||||
test('"connected" applies connected-sidebar class', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'connected')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'floating-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test('"floating" applies floating-sidebar class', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'floating-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test('"floating" + Size "normal" is overridden to connected by overflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'overflowing-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test('"floating" + Size "normal" renders floating in a viewport tall enough to fit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 1500 })
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'floating-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'overflowing-sidebar'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.UI.TabBarLayout', () => {
|
||||
test('"Default" renders integrated tab bar actions container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
|
||||
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
|
||||
})
|
||||
|
||||
test('"Legacy" does not render integrated tab bar actions container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
|
||||
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
|
||||
// The setting writes a CSS var consumed by .p-tree-node-content,
|
||||
// which only renders in the legacy PrimeVue Tree.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
})
|
||||
|
||||
test('low padding (0px) is applied to tree node content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 0)
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
|
||||
.locator('.p-tree-node-content')
|
||||
.first()
|
||||
).toHaveCSS('padding', '0px')
|
||||
})
|
||||
|
||||
test('high padding (8px) is applied to tree node content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 8)
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
|
||||
.locator('.p-tree-node-content')
|
||||
.first()
|
||||
).toHaveCSS('padding', '8px')
|
||||
})
|
||||
})
|
||||
})
|
||||
32
browser_tests/tests/load3d/load3dLod.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
test.describe('Load3D LOD', () => {
|
||||
test(
|
||||
'canvas pixel dimensions scale with ComfyUI canvas zoom level',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
await expect(load3d.canvas).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const initialWidth = await load3d.canvas.evaluate(
|
||||
(el: HTMLCanvasElement) => el.width
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
window.app!.canvas.ds.scale = 2.0
|
||||
node.onResize?.(node.size)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBeGreaterThan(initialWidth)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
@@ -1,117 +1,13 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function openMaskEditorDialog(comfyPage: ComfyPage) {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function getMaskCanvasPixelData(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
// The mask canvas is the 3rd canvas (index 2, z-30)
|
||||
const maskCanvas = canvases[2] as HTMLCanvasElement
|
||||
if (!maskCanvas) return null
|
||||
const ctx = maskCanvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
})
|
||||
}
|
||||
|
||||
function pollMaskPixelCount(page: Page): Promise<number> {
|
||||
return getMaskCanvasPixelData(page).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
async function drawStrokeOnPointerZone(
|
||||
page: Page,
|
||||
dialog: ReturnType<typeof page.locator>
|
||||
) {
|
||||
const pointerZone = dialog.locator(
|
||||
'.maskEditor-ui-container [class*="w-[calc"]'
|
||||
)
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endX, endY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
async function drawStrokeAndExpectPixels(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: ReturnType<typeof comfyPage.page.locator>
|
||||
) {
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { imagePreview } = await maskEditor.loadImageOnNode()
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
@@ -139,8 +35,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
@@ -166,63 +62,61 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('draws a brush stroke on the mask canvas', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
const dataBefore = await maskEditor.getCanvasPixelData(2)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
expect(dataBefore!.nonTransparentPixels).toBe(0)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
})
|
||||
|
||||
test('undo reverts a brush stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('undo reverts a brush stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await expect(undoButton).toBeVisible()
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('redo restores an undone stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('redo restores an undone stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
|
||||
const redoButton = dialog.locator('button[title="Redo"]')
|
||||
await expect(redoButton).toBeVisible()
|
||||
await redoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clear button removes all mask content', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('clear button removes all mask content', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await clearButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('cancel closes the dialog without saving', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await cancelButton.click()
|
||||
@@ -230,10 +124,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('invert button inverts the mask', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('invert button inverts the mask', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
const dataBefore = await maskEditor.getCanvasPixelData(2)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
const pixelsBefore = dataBefore!.nonTransparentPixels
|
||||
|
||||
@@ -242,26 +136,29 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await invertButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(pixelsBefore)
|
||||
})
|
||||
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
||||
await comfyPage.page.keyboard.press(modifier)
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
})
|
||||
|
||||
test(
|
||||
'tool panel shows all five tools',
|
||||
{ tag: ['@smoke'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
||||
await expect(toolPanel).toBeVisible()
|
||||
@@ -279,9 +176,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
|
||||
test('switching tools updates the selected indicator', async ({
|
||||
comfyPage
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
@@ -300,9 +197,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
|
||||
test('brush settings panel is visible with thickness controls', async ({
|
||||
comfyPage
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// The side panel should show brush settings by default
|
||||
const thicknessLabel = dialog.getByText('Thickness')
|
||||
@@ -315,8 +212,11 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(hardnessLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('save uploads all layers and closes dialog', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
@@ -359,8 +259,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
@@ -380,23 +280,23 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
'eraser tool removes mask content',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Draw a stroke with the mask pen (default tool)
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
|
||||
const pixelsAfterDraw = await maskEditor.getCanvasPixelData(2)
|
||||
|
||||
// Switch to eraser tool (3rd tool, index 2)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// Draw over the same area with the eraser
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
|
||||
}
|
||||
)
|
||||
|
||||
100
browser_tests/tests/maskEditorBrushLayers.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
const RGB_PAINT_TOOL_INDEX = 1 // RGB / color paint tool
|
||||
const ERASER_TOOL_INDEX = 2 // Eraser tool
|
||||
|
||||
test.describe(
|
||||
'Mask Editor brush adjustment and layer management',
|
||||
{ tag: '@vue-nodes' },
|
||||
() => {
|
||||
test.describe('Brush settings interaction', () => {
|
||||
test('Adjusting brush thickness slider changes stroke output', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
const thicknessInput = maskEditor.brushInput(dialog, 'thickness')
|
||||
|
||||
// Thin brush
|
||||
await thicknessInput.fill('2')
|
||||
await expect(thicknessInput).toHaveValue('2')
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
const thinPixels = await maskEditor.pollMaskPixelCount()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+z')
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
|
||||
// Thick brush
|
||||
await thicknessInput.fill('200')
|
||||
await expect(thicknessInput).toHaveValue('200')
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(thinPixels)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Layer management', () => {
|
||||
test('Drawing on different tools produces independent mask data', async ({
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
const maskSnapshotAfterPen = await maskEditor.getCanvasSnapshot(2)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
await toolEntries.nth(RGB_PAINT_TOOL_INDEX).click()
|
||||
await expect(toolEntries.nth(RGB_PAINT_TOOL_INDEX)).toHaveClass(
|
||||
/Selected/
|
||||
)
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollRgbPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await expect
|
||||
.poll(() => maskEditor.getCanvasSnapshot(2))
|
||||
.toBe(maskSnapshotAfterPen)
|
||||
})
|
||||
|
||||
test("Switching between tools preserves previous tool's mask data", async ({
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const maskSnapshot = await maskEditor.getCanvasSnapshot(2)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
await toolEntries.nth(ERASER_TOOL_INDEX).click()
|
||||
await expect(toolEntries.nth(ERASER_TOOL_INDEX)).toHaveClass(/Selected/)
|
||||
|
||||
await toolEntries.nth(0).click()
|
||||
await expect(toolEntries.nth(0)).toHaveClass(/Selected/)
|
||||
|
||||
await expect
|
||||
.poll(() => maskEditor.getCanvasSnapshot(2))
|
||||
.toBe(maskSnapshot)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
168
browser_tests/tests/nodeReplacement.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
mockNodeReplacements,
|
||||
mockNodeReplacementsSingle
|
||||
} from '@e2e/fixtures/data/nodeReplacements'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
getSwapNodesGroup,
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
{ name: 'litegraph', vueNodesEnabled: false }
|
||||
] as const
|
||||
|
||||
test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
for (const mode of renderModes) {
|
||||
test.describe(`(${mode.name})`, () => {
|
||||
test.describe('Single replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
mode.vueNodesEnabled
|
||||
)
|
||||
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/node_replacement_simple'
|
||||
)
|
||||
})
|
||||
|
||||
test('Swap Nodes group appears in errors tab for replaceable nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Replace Node replaces a single group in-place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await swapGroup.getByRole('button', { name: /replace node/i }).click()
|
||||
await expect(swapGroup).toBeHidden()
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
expect(
|
||||
workflow.nodes,
|
||||
'Node count should be unchanged after in-place replacement'
|
||||
).toHaveLength(2)
|
||||
|
||||
const nodeTypes = workflow.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).not.toContain('E2E_OldSampler')
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
|
||||
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
|
||||
expect(
|
||||
ksampler?.id,
|
||||
'Replaced node should keep the original id'
|
||||
).toBe(1)
|
||||
|
||||
const linkFromReplacedToDecode = workflow.links?.find(
|
||||
(l) => l[1] === 1 && l[3] === 2
|
||||
)
|
||||
expect(
|
||||
linkFromReplacedToDecode,
|
||||
'Output link from replaced node to VAEDecode should be preserved'
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
test('Widget values are preserved after replacement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await getSwapNodesGroup(comfyPage.page)
|
||||
.getByRole('button', { name: /replace node/i })
|
||||
.click()
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
|
||||
|
||||
expect(ksampler?.widgets_values).toBeDefined()
|
||||
const widgetValues = ksampler!.widgets_values as unknown[]
|
||||
expect(widgetValues).toEqual([
|
||||
42,
|
||||
'randomize',
|
||||
20,
|
||||
7,
|
||||
'euler',
|
||||
'normal',
|
||||
1
|
||||
])
|
||||
})
|
||||
|
||||
test('Success toast is shown after replacement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await getSwapNodesGroup(comfyPage.page)
|
||||
.getByRole('button', { name: /replace node/i })
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.visibleToasts.first()).toContainText(
|
||||
/replaced|swapped/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-type replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
mode.vueNodesEnabled
|
||||
)
|
||||
await setupNodeReplacement(comfyPage, mockNodeReplacements)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/node_replacement_multi'
|
||||
)
|
||||
})
|
||||
|
||||
test('Replace All replaces all groups across multiple types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(swapGroup).toContainText('E2E_OldUpscaler')
|
||||
|
||||
await swapGroup
|
||||
.getByRole('button', { name: 'Replace All', exact: true })
|
||||
.click()
|
||||
await expect(swapGroup).toBeHidden()
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
const nodeTypes = workflow.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).not.toContain('E2E_OldSampler')
|
||||
expect(nodeTypes).not.toContain('E2E_OldUpscaler')
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
expect(nodeTypes).toContain('ImageScaleBy')
|
||||
})
|
||||
|
||||
test('Output connections are preserved across replacement with output mapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await getSwapNodesGroup(comfyPage.page)
|
||||
.getByRole('button', { name: 'Replace All', exact: true })
|
||||
.click()
|
||||
|
||||
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
|
||||
)
|
||||
expect(
|
||||
replacedNodeOutputLinkCount,
|
||||
'Replaced upscaler should still drive its downstream consumer'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph transition (enter and exit)',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }, testInfo) => {
|
||||
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
|
||||
// The signal is dominated by N=80 mount cost, so a single sample per
|
||||
// CI invocation is sufficient — early-return on subsequent repeats.
|
||||
if (testInfo.repeatEachIndex > 0) return
|
||||
|
||||
// Load workflow with a subgraph containing 80 interior nodes.
|
||||
// Entering the subgraph unmounts root nodes and mounts all 80 interior
|
||||
// nodes synchronously — this is the bottleneck we're measuring.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
|
||||
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
// Exit back to root graph before measuring a fresh enter/exit cycle
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.idleFrames(10)
|
||||
|
||||
// Start measuring the enter transition
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('workflow execution', async ({ comfyPage }) => {
|
||||
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
42
browser_tests/tests/previewAsText.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
// Simulate a previous execution: backend returned text and the frontend
|
||||
// populated the preview widget values. The next prompt submission must
|
||||
// NOT echo those values back as inputs (which would change the cache
|
||||
// signature and trigger a redundant re-execution).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.name?.startsWith('preview_')) {
|
||||
widget.value = 'rendered preview content from previous execution'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const previewEntry = Object.values(apiWorkflow).find(
|
||||
(n) => n.class_type === 'PreviewAny'
|
||||
)
|
||||
expect(previewEntry).toBeDefined()
|
||||
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: LOTUS_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps installed models resolved after returning from a nested subgraph', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
const errorsTab = panel.root.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models'),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(errorsTab).toBeHidden()
|
||||
await panel.close()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const requestCountBeforeRootReturn = countAssetRequestsByTag(
|
||||
cloudAssetRequests,
|
||||
'diffusion_models'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models') >
|
||||
requestCountBeforeRootReturn,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,19 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
@@ -31,20 +37,25 @@ const MOCK_JOBS: RawJobListItem[] = [
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
@@ -106,4 +117,64 @@ test.describe('Queue overlay', () => {
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeHidden()
|
||||
})
|
||||
|
||||
test('Job details popover stays inside the viewport for bottom rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
const bottomJob = comfyPage.page.locator(
|
||||
'[data-job-id="job-failed-bottom"]'
|
||||
)
|
||||
await expect(bottomJob).toBeVisible()
|
||||
await bottomJob.scrollIntoViewIfNeeded()
|
||||
await expect(bottomJob).toBeVisible()
|
||||
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) throw new Error('Viewport must be available')
|
||||
|
||||
const rowBox = await bottomJob.boundingBox()
|
||||
if (!rowBox) throw new Error('Bottom job row should be measurable')
|
||||
expect(
|
||||
rowBox.y + rowBox.height,
|
||||
'Test row should be low enough to exercise bottom-edge collision handling'
|
||||
).toBeGreaterThan(viewportSize.height * 0.55)
|
||||
await expect
|
||||
.poll(async () =>
|
||||
bottomJob.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const hitTarget = document.elementFromPoint(
|
||||
rect.x + rect.width / 2,
|
||||
rect.y + rect.height / 2
|
||||
)
|
||||
return hitTarget ? element.contains(hitTarget) : false
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.page.mouse.move(
|
||||
rowBox.x + rowBox.width / 2,
|
||||
rowBox.y + rowBox.height / 2,
|
||||
{ steps: 5 }
|
||||
)
|
||||
|
||||
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const popoverBox = await popover.boundingBox()
|
||||
if (!popoverBox) return false
|
||||
|
||||
return (
|
||||
popoverBox.y >= 0 &&
|
||||
popoverBox.y + popoverBox.height <= viewportSize.height
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const TOTAL_MOCK_JOBS = 20
|
||||
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
|
||||
const overflowJobsListRoutePattern = '**/api/jobs?*'
|
||||
|
||||
function isHistoryJobsRequest(url: string): boolean {
|
||||
@@ -59,7 +60,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
}) => {
|
||||
const TARGET_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
MAX_HISTORY_ITEMS_SETTING,
|
||||
TARGET_LIMIT
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
|
||||
const VISIBLE_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
MAX_HISTORY_ITEMS_SETTING,
|
||||
VISIBLE_LIMIT
|
||||
)
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
63
browser_tests/tests/queueButtonModes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
|
||||
const runOnChangeLabel = queueModeLabels[1]
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await expect(options.modeItems).toHaveText(queueModeLabels)
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const queueButton = comfyPage.actionbar.queueButton
|
||||
const options = await queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await options.selectMode(runOnChangeLabel)
|
||||
|
||||
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
|
||||
})
|
||||
|
||||
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route('**/api/prompt', async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ test.describe(
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -2,21 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the context menu to appear by checking for 'Copy', which is
|
||||
// always present regardless of single or multi-node selection.
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
|
||||
}
|
||||
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
|
||||
|
||||
test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
test.describe('Single node actions', () => {
|
||||
@@ -34,14 +20,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(nodeRef).not.toBePinned()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Pin', { exact: true }).click()
|
||||
let menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Pin', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).toBePinned()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Unpin', { exact: true }).click()
|
||||
menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Unpin', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).not.toBePinned()
|
||||
@@ -57,14 +43,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(nodeRef).not.toBeCollapsed()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Minimize Node', { exact: true }).click()
|
||||
let menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Minimize Node', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).toBeCollapsed()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Expand Node', { exact: true }).click()
|
||||
menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Expand Node', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).not.toBeCollapsed()
|
||||
@@ -78,8 +64,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Copy', { exact: true }).click()
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Copy', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Paste the copied node
|
||||
@@ -99,8 +85,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Duplicate', { exact: true }).click()
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Duplicate', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
96
browser_tests/tests/selectionToolboxRename.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
|
||||
|
||||
test.describe('Selection toolbox rename', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Single rename', () => {
|
||||
test('Rename via More Options opens title editor for single node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Rename', { exact: true }).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('node-title-input')).toHaveValue(
|
||||
'KSampler'
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename shows prompt dialog for group', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerGroupPos = await getGroupTitlePosition(
|
||||
comfyPage,
|
||||
'Outer Group'
|
||||
)
|
||||
await comfyPage.canvas.click({ position: outerGroupPos })
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Rename', { exact: true }).click()
|
||||
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('Renamed Group')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.groups.some(
|
||||
(g) => g.title === 'Renamed Group'
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Batch rename', () => {
|
||||
test('Batch rename multiple selected nodes', async ({ comfyPage }) => {
|
||||
const ksampler = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
const emptyLatent = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Empty Latent Image')
|
||||
)[0]
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Rename', { exact: true }).click()
|
||||
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('TestNode')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titles = await Promise.all([
|
||||
ksampler.getProperty<string>('title'),
|
||||
emptyLatent.getProperty<string>('title')
|
||||
])
|
||||
return [...titles].sort()
|
||||
})
|
||||
.toEqual(['TestNode 1', 'TestNode 2'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Nested subgraph configure order', () => {
|
||||
@@ -190,4 +191,106 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph input target resolution',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
|
||||
const OUTER_NODE_ID = '5'
|
||||
const INNER_SUBGRAPH_NODE_ID = '6'
|
||||
|
||||
test('Nested SubgraphNode promoted widgets render without resolution failures', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page,
|
||||
['No link found', 'Failed to resolve legacy -1']
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(
|
||||
widgets,
|
||||
'asset has 4 promoted widgets on outer subgraph node'
|
||||
).toHaveCount(4)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgets).toHaveCount(4)
|
||||
|
||||
const valueWidget = outerNode
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const widgets = await getPromotedWidgets(comfyPage, OUTER_NODE_ID)
|
||||
return widgets
|
||||
.filter(
|
||||
([sourceNodeId]) => sourceNodeId === INNER_SUBGRAPH_NODE_ID
|
||||
)
|
||||
.map(([, sourceWidgetName]) => sourceWidgetName)
|
||||
})
|
||||
.toContain('value')
|
||||
})
|
||||
|
||||
test('Serialize and reload preserves nested promoted widget visibility', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(
|
||||
widgets,
|
||||
'asset has 4 promoted widgets on outer subgraph node'
|
||||
).toHaveCount(4)
|
||||
const initialCount = await widgets.count()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
||||
|
||||
const valueWidget = outerNodeAfter
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -535,6 +535,28 @@ test.describe(
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
|
||||
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => getPromotedWidgetCount(comfyPage, '2'),
|
||||
'Primitive widget is restored on load'
|
||||
)
|
||||
.toBe(2)
|
||||
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
const promotedPrimitive = await subgraphNode!.getWidget(1)
|
||||
await expect
|
||||
.poll(
|
||||
() => promotedPrimitive.getValue(),
|
||||
'Primitive widget is not in a disconnected state'
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -125,6 +125,48 @@ test.describe('Workflow tabs', () => {
|
||||
await expect(activeTab.locator('text=•')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can drag tab to end', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
|
||||
const [a, b, c] = await topbar.getTabNames()
|
||||
|
||||
await topbar.getTab(0).dragTo(topbar.getTab(2))
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toEqual([b, c, a])
|
||||
})
|
||||
|
||||
test('Can drag tab to start', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
|
||||
const [a, b, c] = await topbar.getTabNames()
|
||||
|
||||
await topbar.getTab(2).dragTo(topbar.getTab(0))
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toEqual([c, a, b])
|
||||
})
|
||||
|
||||
test('Drag preserves active tab', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
|
||||
|
||||
const [, b] = await topbar.getTabNames()
|
||||
await topbar.getTab(1).click()
|
||||
await expect.poll(() => topbar.getActiveTabName()).toContain(b)
|
||||
|
||||
await topbar.getTab(0).dragTo(topbar.getTab(2))
|
||||
|
||||
await expect.poll(() => topbar.getActiveTabName()).toContain(b)
|
||||
})
|
||||
|
||||
test('Multiple tabs can be created, switched, and closed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -20,6 +21,7 @@ const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -71,6 +73,59 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test(
|
||||
'highlights the missing required input slot',
|
||||
{ tag: ['@screenshot', '@node'] },
|
||||
async ({ comfyPage }) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
|
||||
const modelInputIndex = await comfyPage.page.evaluate(
|
||||
({ nodeId, inputName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const index =
|
||||
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
modelInputIndex
|
||||
)
|
||||
const modelInputSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
ksamplerId,
|
||||
modelInputIndex
|
||||
)
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[ksamplerId]: buildKSamplerError(
|
||||
'required_input_missing',
|
||||
KSAMPLER_MODEL_INPUT_NAME,
|
||||
`Required input is missing: ${KSAMPLER_MODEL_INPUT_NAME}`
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
await expect(modelInputSlotRow).toBeVisible()
|
||||
await expect(modelInputSlotRow).toBeInViewport()
|
||||
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
await comfyPage.expectScreenshot(
|
||||
ksamplerNode,
|
||||
'vue-node-required-input-missing-slot-error.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('clears error ring when user edits an out-of-range number widget back into range', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
49
browser_tests/tests/workflowDeleteSettings.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW_NAME = 'test-confirm-delete'
|
||||
|
||||
async function startDeletingFromSidebar(comfyPage: ComfyPage) {
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(WORKFLOW_NAME).click({ button: 'right' })
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
}
|
||||
|
||||
test.describe('Comfy.Workflow.ConfirmDelete', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflowAs(WORKFLOW_NAME)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('on (default): right-click → Delete prompts the confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', true)
|
||||
|
||||
await startDeletingFromSidebar(comfyPage)
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
await expect(comfyPage.confirmDialog.delete).toBeVisible()
|
||||
})
|
||||
|
||||
test('off: right-click → Delete bypasses the confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
|
||||
await startDeletingFromSidebar(comfyPage)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await expect(comfyPage.confirmDialog.root).toBeHidden()
|
||||
await expect
|
||||
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
|
||||
.not.toContain(WORKFLOW_NAME)
|
||||
})
|
||||
})
|
||||
@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Lodash Functions
|
||||
## Mocking Utility Functions
|
||||
|
||||
Mocking utility functions like debounce:
|
||||
|
||||
|
||||