Compare commits
16 Commits
feat/app-m
...
core/1.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c67099a9f | ||
|
|
2d9b1fed64 | ||
|
|
deb4045f18 | ||
|
|
0b3927d8d5 | ||
|
|
955472dab5 | ||
|
|
4ad242181b | ||
|
|
16dfc33df3 | ||
|
|
1a8bf498ef | ||
|
|
7b8ad1c11b | ||
|
|
364bcb3831 | ||
|
|
a6699f6922 | ||
|
|
962e70d7a5 | ||
|
|
6193b76157 | ||
|
|
c5c916f80e | ||
|
|
badc97b982 | ||
|
|
67affd2075 |
@@ -32,12 +32,12 @@
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
# 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 "${TEST_FILES[@]}"
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit <test-file>
|
||||
pnpm test:unit -- <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
|
||||
@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit --update
|
||||
pnpm test:unit -- --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
24
.github/workflows/detect-unreviewed-merge.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Detect Unreviewed Merge
|
||||
|
||||
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
|
||||
# tracking issues are filed in Comfy-Org/unreviewed-merges.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: detect-unreviewed-merge-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
|
||||
with:
|
||||
approval-mode: latest-per-reviewer
|
||||
secrets:
|
||||
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}
|
||||
14
AGENTS.md
@@ -307,20 +307,6 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
111
CODEOWNERS
@@ -1,60 +1,95 @@
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @comfy_frontend_devs
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
|
||||
/src/components/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/imagecrop/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useImageCrop.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/painter/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/painter/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @comfy_frontend_devs
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/load3d/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3d.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/services/load3dService.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @comfy_frontend_devs
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987 @comfy_frontend_devs
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
BIN
apps/website/public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" rx="12" fill="#211927"/>
|
||||
<path fill="#F2FF59" d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z"/>
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
3
apps/website/public/icons/clients/EA.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "Comfy",
|
||||
"short_name": "Comfy",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -87,8 +87,8 @@ function scrollToDepartment(deptKey: string) {
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:gap-20">
|
||||
<div class="shrink-0 lg:min-w-48">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<div class="shrink-0 md:w-48">
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
|
||||
>
|
||||
@@ -133,41 +133,30 @@ function scrollToDepartment(deptKey: string) {
|
||||
:href="role.jobUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 hover:border-primary-comfy-canvas group flex items-center gap-4 border-b py-5 transition-colors duration-200"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col md:flex-row md:items-baseline md:gap-x-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
|
||||
>
|
||||
{{ role.title }}
|
||||
</span>
|
||||
<div
|
||||
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm md:mt-0 md:contents"
|
||||
>
|
||||
<span>{{ role.department }}</span>
|
||||
<span class="md:hidden">{{ role.location }}</span>
|
||||
</div>
|
||||
<span class="text-primary-warm-gray ml-3 text-sm">
|
||||
{{ role.department }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-primary-warm-gray hidden shrink-0 text-sm md:inline"
|
||||
>
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow/0 group-hover:bg-primary-comfy-yellow relative grid size-7 shrink-0 place-items-center rounded-sm transition-colors duration-300 ease-out"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors duration-300 ease-out"
|
||||
style="
|
||||
mask: url('/icons/arrow-up-right.svg') center / contain
|
||||
no-repeat;
|
||||
"
|
||||
<div class="ml-4 flex shrink-0 items-center gap-3">
|
||||
<span class="text-primary-warm-gray text-sm">
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<img
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex w-full scrollbar-none items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
class="scrollbar-none flex items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type NavDropdownItem = {
|
||||
export type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
|
||||
@@ -37,7 +37,7 @@ const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
|
||||
cardDef('local', routes.download, 'bg-primary-warm-gray'),
|
||||
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
|
||||
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-secondary-cool-gray')
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
|
||||
]
|
||||
|
||||
const cards = excludeProduct
|
||||
|
||||
@@ -3,6 +3,7 @@ const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'EA',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
|
||||
const {
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
items,
|
||||
@@ -251,7 +251,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Thumbnail strip -->
|
||||
<div
|
||||
class="mx-auto mt-6 h-16 max-w-full scrollbar-none overflow-x-auto px-6 lg:h-30"
|
||||
class="scrollbar-none mx-auto mt-6 h-16 max-w-full overflow-x-auto px-6 lg:h-30"
|
||||
>
|
||||
<div class="flex items-end gap-3">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
item,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { visibleGalleryItems as items } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryCard from './GalleryCard.vue'
|
||||
import GalleryDetailModal from './GalleryDetailModal.vue'
|
||||
@@ -18,6 +16,166 @@ function openDetail(index: number) {
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
export interface GalleryItem {
|
||||
image?: string
|
||||
video?: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins - Kyrie Irving',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
|
||||
title: 'Neon Nights',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'MidJourney man',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
|
||||
title: 'Eat It - Dance',
|
||||
userAlias: 'Johana Lyu',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.joannalyu.com/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
|
||||
title: 'Fall',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
|
||||
title: 'Origami world',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
|
||||
title: 'Shot on InstaX',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
|
||||
title: "It's gonna be a good good summer",
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685900'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
|
||||
title: 'DDU-DU DDU-DU',
|
||||
userAlias: 'Purz',
|
||||
teamAlias: 'Andidea',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://vimeo.com/1019924290'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
|
||||
title: 'Cuco - A Love Letter To LA',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: 'CoffeeVectors',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1062859798'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
|
||||
title: 'Show you my garden',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685479'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
|
||||
title: 'Goodbye Beijing',
|
||||
userAlias: 'Rui',
|
||||
teamAlias: 'makeitrad',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://x.com/rui40000'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
|
||||
title: 'Animation Reel',
|
||||
userAlias: 'Andidea',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Desktop layout pattern (repeating):
|
||||
* Row A: full-width (1 item)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
@@ -115,6 +116,8 @@ const plans: PricingPlan[] = [
|
||||
|
||||
const standardPlans = plans.filter((p) => !p.isEnterprise)
|
||||
const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
|
||||
const activePlanIndex = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,7 +134,28 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
|
||||
<!-- Mobile plan tabs -->
|
||||
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
|
||||
<button
|
||||
v-for="(plan, index) in plans"
|
||||
:key="plan.id"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 rounded-full px-4 py-2 text-xs font-bold tracking-wider transition-colors',
|
||||
activePlanIndex === index
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-transparency-white-t4 text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
@click="activePlanIndex = index"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t(plan.labelKey, locale) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -249,9 +273,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</PricingTierCard>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: stacked plans -->
|
||||
<div class="flex flex-col gap-8 lg:hidden">
|
||||
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
|
||||
<!-- Mobile: single plan view -->
|
||||
<div class="lg:hidden">
|
||||
<div
|
||||
v-for="(plan, index) in plans"
|
||||
:key="plan.id"
|
||||
:class="cn('flex-col', activePlanIndex !== index ? 'hidden' : 'flex')"
|
||||
>
|
||||
<!-- Main info card -->
|
||||
<div class="bg-transparency-white-t4 rounded-3xl p-6">
|
||||
<!-- Label + badge -->
|
||||
|
||||
@@ -11,12 +11,14 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto mb-12 flex flex-col items-center overflow-hidden px-4 md:flex-row md:overflow-visible md:pt-20 lg:items-center lg:space-x-20"
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-visible lg:pb-[min(8vw,10rem)]"
|
||||
>
|
||||
<!-- Illustration (stacks above on mobile, left on lg) -->
|
||||
<div class="pointer-events-none mx-auto w-full flex-1 md:-translate-x-20">
|
||||
<div
|
||||
class="aspect-square w-4/5 max-w-md scale-125 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-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
|
||||
class="mx-auto block size-full max-w-lg overflow-visible md:ml-auto md:scale-125"
|
||||
class="block size-full overflow-visible"
|
||||
viewBox="50 50 900 900"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
@@ -376,7 +378,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="relative z-10 lg:flex-1">
|
||||
<div
|
||||
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[25%] lg:px-20 lg:py-14"
|
||||
>
|
||||
<ProductHeroBadge text="CLOUD" />
|
||||
|
||||
<h1
|
||||
@@ -386,7 +390,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-6 max-w-lg text-sm lg:mt-6 lg:text-base"
|
||||
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
|
||||
>
|
||||
{{ t('cloud.hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -168,7 +168,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<!-- Illustration (stacks above on mobile, left on lg) -->
|
||||
<div
|
||||
class="aspect-550/800 w-4/5 max-w-xs self-center overflow-visible md:max-w-sm lg:pointer-events-none lg:z-1 lg:-mr-12 lg:max-w-md lg:translate-x-[10%] lg:translate-y-20 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-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 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"
|
||||
|
||||
@@ -12,7 +12,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
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-sm font-semibold lg:text-sm lg:font-normal"
|
||||
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
|
||||
>
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('download.cloud.prefix', locale) }}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
export interface GalleryItem {
|
||||
id: string
|
||||
image?: string
|
||||
video?: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
/** Defaults to true. Set to false to hide this item from rendered lists. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const galleryItems: GalleryItem[] = [
|
||||
{
|
||||
id: 'until-our-eye-interlink-harajuku',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
id: 'origins-kyrie-irving',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins - Kyrie Irving',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
id: 'neon-nights',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
|
||||
title: 'Neon Nights',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
|
||||
},
|
||||
{
|
||||
id: 'untitled-dusk-mountains',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'MidJourney man',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
|
||||
},
|
||||
{
|
||||
id: 'autopoiesis',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
id: 'eat-it-dance',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
|
||||
title: 'Eat It - Dance',
|
||||
userAlias: 'Johana Lyu',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.joannalyu.com/'
|
||||
},
|
||||
{
|
||||
id: 'fall',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
|
||||
title: 'Fall',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
|
||||
},
|
||||
{
|
||||
id: 'untitled-buildings',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
|
||||
},
|
||||
{
|
||||
id: 'origami-world',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
|
||||
title: 'Origami world',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
id: 'shot-on-instax',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
|
||||
title: 'Shot on InstaX',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
id: 'good-good-summer',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
|
||||
title: "It's gonna be a good good summer",
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685900'
|
||||
},
|
||||
{
|
||||
id: 'ddu-du-ddu-du',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
|
||||
title: 'DDU-DU DDU-DU',
|
||||
userAlias: 'Purz',
|
||||
teamAlias: 'Andidea',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://vimeo.com/1019924290'
|
||||
},
|
||||
{
|
||||
id: 'cuco-love-letter-to-la',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
|
||||
title: 'Cuco - A Love Letter To LA',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: 'CoffeeVectors',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1062859798'
|
||||
},
|
||||
{
|
||||
id: 'show-you-my-garden',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
|
||||
title: 'Show you my garden',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685479'
|
||||
},
|
||||
{
|
||||
id: 'goodbye-beijing',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
|
||||
title: 'Goodbye Beijing',
|
||||
userAlias: 'Rui',
|
||||
teamAlias: 'makeitrad',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://x.com/rui40000'
|
||||
},
|
||||
{
|
||||
id: 'animation-reel',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
|
||||
title: 'Animation Reel',
|
||||
userAlias: 'Andidea',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
|
||||
},
|
||||
{
|
||||
id: 'amber-astronaut',
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
id: 'desert-landing',
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
|
||||
(item) => item.visible !== false
|
||||
)
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export function getGalleryItemById(id: string): GalleryItem | undefined {
|
||||
return galleryItems.find((item) => item.id === id)
|
||||
}
|
||||
@@ -1458,9 +1458,9 @@ const translations = {
|
||||
// ContactSection
|
||||
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
|
||||
'gallery.contact.heading': {
|
||||
en: 'Built something cool with ComfyUI?<br> <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
|
||||
en: 'Built something cool with ComfyUI? <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
|
||||
'zh-CN':
|
||||
'用 ComfyUI 创作了很酷的作品?<br><a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
|
||||
'用 ComfyUI 创作了很酷的作品?<a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
|
||||
},
|
||||
|
||||
// AboutHeroSection
|
||||
|
||||
@@ -71,12 +71,10 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#211927" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -20,9 +18,7 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
interface DroppedRole {
|
||||
export interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
interface DroppedNode {
|
||||
export interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
fetchGitHubStars,
|
||||
formatStarCount,
|
||||
resetGitHubStarsFetcherForTests
|
||||
} from './github'
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
afterEach(() => {
|
||||
resetGitHubStarsFetcherForTests()
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
@@ -32,67 +27,6 @@ describe('fetchGitHubStars', () => {
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
})
|
||||
|
||||
it('memoizes concurrent fetches for the same repo to one network call', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ stargazers_count: 110000 }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
)
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
])
|
||||
|
||||
expect(a).toBe(110000)
|
||||
expect(b).toBe(110000)
|
||||
expect(c).toBe(110000)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keys the in-flight cache by owner/repo', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
||||
const href = typeof url === 'string' ? url : url.toString()
|
||||
const count = href.includes('other-repo') ? 42 : 110000
|
||||
return new Response(JSON.stringify({ stargazers_count: count }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
})
|
||||
|
||||
const [comfy, other] = await Promise.all([
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
|
||||
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
|
||||
])
|
||||
|
||||
expect(comfy).toBe(110000)
|
||||
expect(other).toBe(42)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns null when GitHub responds non-2xx', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () => new Response('rate limited', { status: 403 })
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when fetch throws', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('network down')
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStarCount', () => {
|
||||
|
||||
@@ -1,51 +1,22 @@
|
||||
const inflight = new Map<string, Promise<number | null>>()
|
||||
|
||||
export function resetGitHubStarsFetcherForTests(): void {
|
||||
inflight.clear()
|
||||
}
|
||||
|
||||
export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
|
||||
const key = `${owner}/${repo}`
|
||||
const cached = inflight.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const request = doFetch(owner, repo, fetchImpl)
|
||||
inflight.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
fetchImpl: typeof fetch
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetchImpl(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json' } }
|
||||
)
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data: unknown = await res.json()
|
||||
return readStargazerCount(data)
|
||||
const data = await res.json()
|
||||
return data.stargazers_count ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readStargazerCount(data: unknown): number | null {
|
||||
if (data === null || typeof data !== 'object') return null
|
||||
if (!('stargazers_count' in data)) return null
|
||||
const count = data.stargazers_count
|
||||
return typeof count === 'number' ? count : null
|
||||
}
|
||||
|
||||
export function formatStarCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
const m = count / 1_000_000
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["first-host", 11]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [900, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["second-host", 22]
|
||||
}
|
||||
],
|
||||
"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": [400, 200],
|
||||
"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": [400, 200],
|
||||
"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": [400, 200],
|
||||
"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
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
{
|
||||
"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": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["9999", "missing_widget"]]
|
||||
},
|
||||
"widgets_values": ["quarantined-host-value"]
|
||||
}
|
||||
],
|
||||
"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": [400, 200],
|
||||
"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": [400, 200],
|
||||
"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": [400, 200],
|
||||
"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
|
||||
}
|
||||
@@ -66,34 +66,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.drop(options)
|
||||
}
|
||||
|
||||
async middleDrag(
|
||||
from: Position,
|
||||
to: Position,
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
|
||||
}
|
||||
|
||||
async middleDragFromCenter(
|
||||
locator: Locator,
|
||||
delta: { x: number; y: number },
|
||||
options: Omit<DragOptions, 'button'> = {}
|
||||
) {
|
||||
await locator.waitFor({ state: 'visible' })
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('middleDragFromCenter: bounding box not found')
|
||||
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
await this.middleDrag(
|
||||
start,
|
||||
{ x: start.x + delta.x, y: start.y + delta.y },
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
/** @see {@link Mouse.move} */
|
||||
async move(to: Position, options = ComfyMouse.defaultOptions) {
|
||||
await this.mouse.move(to.x, to.y, options)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
class UserSelectPage {
|
||||
export class UserSelectPage {
|
||||
public readonly selectionUrl: string
|
||||
public readonly container: Locator
|
||||
public readonly newUserInput: Locator
|
||||
|
||||
@@ -18,7 +18,7 @@ class ShortcutsTab {
|
||||
}
|
||||
}
|
||||
|
||||
class LogsTab {
|
||||
export class LogsTab {
|
||||
readonly tab: Locator
|
||||
readonly terminalRoot: Locator
|
||||
readonly terminalHost: Locator
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
class ComfyNodeSearchFilterSelectionPanel {
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
readonly header: Locator
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
|
||||
@@ -27,6 +27,10 @@ export class ContextMenu {
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
menuItem(name: string): Locator {
|
||||
return this.anyMenu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a litegraph menu entry. Selects the most recently opened matching
|
||||
* entry so nested submenu items can be reached without being shadowed by
|
||||
|
||||
@@ -139,7 +139,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly activeWorkflowLabel: Locator
|
||||
public readonly searchInput: Locator
|
||||
public readonly refreshButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
@@ -148,9 +147,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
this.searchInput = this.root.getByRole('combobox').first()
|
||||
this.refreshButton = this.root.getByTestId(
|
||||
TestIds.sidebar.workflowsRefreshButton
|
||||
)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
|
||||
@@ -17,9 +17,8 @@ export class SubgraphEditor {
|
||||
)
|
||||
}
|
||||
|
||||
async ensureOpen(subgraphNode: Locator) {
|
||||
async open(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
if (await this.root.isVisible()) return
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
@@ -70,7 +69,7 @@ export class SubgraphEditor {
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.ensureOpen(subgraphNode)
|
||||
await this.open(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
|
||||
@@ -2,3 +2,8 @@ export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
@@ -86,6 +86,46 @@ export const STABLE_LORA: Asset = createModelAsset({
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA_2: Asset = createModelAsset({
|
||||
id: 'test-lora-002',
|
||||
name: 'add_detail_v2.safetensors',
|
||||
size: 226_492_416,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Add Detail LoRA v2'
|
||||
},
|
||||
created_at: '2025-02-25T11:00:00Z',
|
||||
updated_at: '2025-02-25T11:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_VAE: Asset = createModelAsset({
|
||||
id: 'test-vae-001',
|
||||
name: 'sdxl_vae.safetensors',
|
||||
size: 334_641_152,
|
||||
tags: ['models', 'vae'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'SDXL VAE'
|
||||
},
|
||||
created_at: '2025-01-18T16:00:00Z',
|
||||
updated_at: '2025-01-18T16:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_EMBEDDING: Asset = createModelAsset({
|
||||
id: 'test-embedding-001',
|
||||
name: 'bad_prompt_v2.pt',
|
||||
size: 32_768,
|
||||
mime_type: 'application/x-pytorch',
|
||||
tags: ['models', 'embeddings'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Negative Embedding: Bad Prompt v2'
|
||||
},
|
||||
created_at: '2025-02-01T09:30:00Z',
|
||||
updated_at: '2025-02-01T09:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
@@ -96,6 +136,26 @@ export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
updated_at: '2025-03-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
|
||||
id: 'test-input-002',
|
||||
name: 'mask_layer.png',
|
||||
size: 1_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-05T10:00:00Z',
|
||||
updated_at: '2025-03-05T10:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
|
||||
id: 'test-input-003',
|
||||
name: 'clip_720p.mp4',
|
||||
size: 15_728_640,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-08T14:30:00Z',
|
||||
updated_at: '2025-03-08T14:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
id: 'test-output-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
@@ -106,6 +166,31 @@ export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
updated_at: '2025-03-10T12:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
|
||||
id: 'test-output-002',
|
||||
name: 'ComfyUI_00002_.png',
|
||||
size: 3_670_016,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:05:00Z',
|
||||
updated_at: '2025-03-10T12:05:00Z'
|
||||
})
|
||||
export const ALL_MODEL_FIXTURES: Asset[] = [
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2,
|
||||
STABLE_LORA,
|
||||
STABLE_LORA_2,
|
||||
STABLE_VAE,
|
||||
STABLE_EMBEDDING
|
||||
]
|
||||
|
||||
export const ALL_INPUT_FIXTURES: Asset[] = [
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_INPUT_IMAGE_2,
|
||||
STABLE_INPUT_VIDEO
|
||||
]
|
||||
|
||||
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
|
||||
const CHECKPOINT_NAMES = [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
|
||||
155
browser_tests/fixtures/data/nodeDefinitions.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -6,9 +6,8 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by `nodeId:widgetName` suffix against the
|
||||
* `data-widget-key` attribute, which carries the canonical
|
||||
* `graphId:nodeId:widgetName` WidgetEntityId.
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -21,9 +20,9 @@ export class AppModeWidgetHelper {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
getWidgetItem(key: string): Locator {
|
||||
return this.container.locator(`[data-widget-key$=":${key}"]`)
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
}
|
||||
|
||||
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
generateOutputAssets
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
interface MutationRecord {
|
||||
export interface MutationRecord {
|
||||
endpoint: string
|
||||
method: string
|
||||
url: string
|
||||
@@ -23,7 +23,7 @@ interface PaginationOptions {
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
interface AssetConfig {
|
||||
export interface AssetConfig {
|
||||
readonly assets: ReadonlyMap<string, Asset>
|
||||
readonly pagination: PaginationOptions | null
|
||||
readonly uploadResponse: Record<string, unknown> | null
|
||||
@@ -33,7 +33,7 @@ function emptyConfig(): AssetConfig {
|
||||
return { assets: new Map(), pagination: null, uploadResponse: null }
|
||||
}
|
||||
|
||||
type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
export type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
|
||||
@@ -26,7 +26,7 @@ const historyRoutePattern = /\/api\/history$/
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
@@ -134,6 +134,16 @@ export function createJobsWithExecutionTimes(
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
|
||||
type HelpMenuItemKey =
|
||||
export type HelpMenuItemKey =
|
||||
| 'feedback'
|
||||
| 'help'
|
||||
| 'docs'
|
||||
@@ -17,7 +17,7 @@ type HelpMenuItemKey =
|
||||
| 'update-comfyui'
|
||||
| 'more'
|
||||
|
||||
class HelpCenterHelper {
|
||||
export class HelpCenterHelper {
|
||||
public readonly button: Locator
|
||||
public readonly popup: Locator
|
||||
public readonly backdrop: Locator
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
const RGB_CANVAS_INDEX = 1
|
||||
|
||||
type BrushSliderLabel = 'thickness'
|
||||
export type BrushSliderLabel = 'thickness'
|
||||
|
||||
class MaskEditorHelper {
|
||||
export class MaskEditorHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -9,7 +9,7 @@ const modelFoldersRoutePattern = /\/api\/experiment\/models$/
|
||||
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
|
||||
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
|
||||
|
||||
interface MockModelMetadata {
|
||||
export interface MockModelMetadata {
|
||||
'modelspec.title'?: string
|
||||
'modelspec.author'?: string
|
||||
'modelspec.architecture'?: string
|
||||
@@ -18,11 +18,14 @@ interface MockModelMetadata {
|
||||
'modelspec.tags'?: string
|
||||
}
|
||||
|
||||
function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
return names.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
function createMockModelFiles(filenames: string[], pathIndex = 0): ModelFile[] {
|
||||
export function createMockModelFiles(
|
||||
filenames: string[],
|
||||
pathIndex = 0
|
||||
): ModelFile[] {
|
||||
return filenames.map((name) => ({ name, pathIndex }))
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,16 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
const node = await this.getFirstNodeRef()
|
||||
if (!node) {
|
||||
throw new Error('No nodes found to convert')
|
||||
}
|
||||
await node.clickContextMenuOption('Convert to Group Node')
|
||||
await this.fillPromptDialog(groupNodeName)
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
await this.promptDialogInput.fill(value)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -43,7 +43,7 @@ const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
class PublishApiHelper {
|
||||
export class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import { SubgraphBreadcrumbPanel } from '@e2e/fixtures/components/SubgraphBreadcrumbPanel'
|
||||
|
||||
class SubgraphBreadcrumbHelper {
|
||||
export class SubgraphBreadcrumbHelper {
|
||||
readonly panel: SubgraphBreadcrumbPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
|
||||
@@ -14,8 +14,6 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
export class SubgraphHelper {
|
||||
public readonly editor: SubgraphEditor
|
||||
@@ -425,9 +423,39 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return getAllHostPromotedWidgets(this.comfyPage)
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
|
||||
@@ -4,9 +4,33 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
interface TemplateConfig {
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
@@ -15,7 +39,7 @@ function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
@@ -38,6 +62,46 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
|
||||
@@ -121,7 +121,7 @@ export function createRouteMockJob({
|
||||
}
|
||||
}
|
||||
|
||||
class JobsRouteMocker {
|
||||
export class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
|
||||
@@ -10,7 +10,6 @@ export const TestIds = {
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
workflowsRefreshButton: 'workflows-refresh-button',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
tree: {
|
||||
@@ -129,15 +128,15 @@ export const TestIds = {
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image',
|
||||
slotConnectionDot: 'slot-connection-dot',
|
||||
imageGrid: 'image-grid'
|
||||
slotConnectionDot: 'slot-connection-dot'
|
||||
},
|
||||
selectionToolbox: {
|
||||
root: 'selection-toolbox',
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
@@ -303,3 +302,12 @@ export const TestIds = {
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
}
|
||||
} as const
|
||||
|
||||
export type TestId<K extends keyof typeof TestIds> = Exclude<
|
||||
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
|
||||
(...args: never[]) => string
|
||||
>
|
||||
|
||||
export type TestIdValue = {
|
||||
[K in keyof typeof TestIds]: TestId<K>
|
||||
}[keyof typeof TestIds]
|
||||
|
||||
@@ -19,7 +19,7 @@ export const sharedWorkflowImportScenario = {
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
type SharedWorkflowRequestEvent =
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { Page } from '@playwright/test'
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
|
||||
interface MeasureResult {
|
||||
export type { CanvasRect }
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ class NodeSlotReference {
|
||||
}
|
||||
}
|
||||
|
||||
class NodeWidgetReference {
|
||||
export class NodeWidgetReference {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
@@ -511,7 +511,19 @@ export class NodeReference {
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
await this.comfyPage.contextMenu.clickMenuItem(optionText)
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'path'
|
||||
|
||||
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
|
||||
interface PerfReport {
|
||||
export interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
|
||||
@@ -1,75 +1,48 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function previewExposureToEntry(
|
||||
exposure: PreviewExposure
|
||||
): PromotedWidgetEntry {
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
@@ -105,29 +78,12 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.filter(([, name]) => name === widgetName).length
|
||||
}
|
||||
|
||||
export async function getAllHostPromotedWidgets(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
|
||||
const hostNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => String(node.id))
|
||||
})
|
||||
|
||||
const entries = await Promise.all(
|
||||
hostNodeIds.map(async (hostNodeId) => ({
|
||||
hostNodeId,
|
||||
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
|
||||
}))
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
|
||||
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
interface SlotMeasurement {
|
||||
export interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
export interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
interface BoxOrigin {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
public readonly header: Locator
|
||||
@@ -22,9 +15,7 @@ export class VueNodeFixture {
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
public readonly imageGrid: Locator
|
||||
public readonly content: Locator
|
||||
public readonly resize: { bottomRight: Locator }
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -37,10 +28,7 @@ export class VueNodeFixture {
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
|
||||
this.content = locator.locator('.lg-node-content')
|
||||
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
|
||||
this.resize = { bottomRight }
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -89,100 +77,4 @@ export class VueNodeFixture {
|
||||
: slotLocators.filter({ has: nameOrLocator })
|
||||
return filteredLocator.getByTestId('slot-dot').locator('..')
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the node header to select it, then return its bounding box.
|
||||
* Throws if the node is not laid out because geometry-sensitive tests
|
||||
* cannot proceed without coordinates.
|
||||
*/
|
||||
async selectAndGetBox(): Promise<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}> {
|
||||
await this.header.click()
|
||||
const box = await this.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Node bounding box not found after select')
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert this node's top-left origin stays within `precision` decimal
|
||||
* places of `expected`. Wraps the polled bounding-box pattern that drift
|
||||
* tests repeat for both axes.
|
||||
*/
|
||||
async expectAnchoredAt(
|
||||
expected: BoxOrigin,
|
||||
{ precision = 1 }: { precision?: number } = {}
|
||||
): Promise<void> {
|
||||
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
|
||||
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
|
||||
}
|
||||
|
||||
/** Poll the node's left/x edge for use with `expect.poll`. */
|
||||
pollLeftEdge = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.x ?? null
|
||||
|
||||
/** Poll the node's top/y edge for use with `expect.poll`. */
|
||||
pollTopEdge = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.y ?? null
|
||||
|
||||
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
|
||||
pollRightEdge = async (): Promise<number | null> => {
|
||||
const b = await this.boundingBox()
|
||||
return b ? b.x + b.width : null
|
||||
}
|
||||
|
||||
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
|
||||
pollBottomEdge = async (): Promise<number | null> => {
|
||||
const b = await this.boundingBox()
|
||||
return b ? b.y + b.height : null
|
||||
}
|
||||
|
||||
/** Poll the node's width for use with `expect.poll`. */
|
||||
pollWidth = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.width ?? null
|
||||
|
||||
/** Poll the node's height for use with `expect.poll`. */
|
||||
pollHeight = async (): Promise<number | null> =>
|
||||
(await this.boundingBox())?.height ?? null
|
||||
|
||||
/** Locator for the resize handle at the given corner, scoped to this node. */
|
||||
getResizeHandle(corner: CompassCorners): Locator {
|
||||
return this.root.locator(`[data-corner="${corner}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
|
||||
* Uses `hover()` to land the pointer on the handle with Playwright's
|
||||
* actionability checks before starting the mouse sequence, which protects
|
||||
* against occluding overlays and subpixel hit-test misses.
|
||||
*/
|
||||
async resizeFromCorner(
|
||||
corner: CompassCorners,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): Promise<void> {
|
||||
const handle = this.getResizeHandle(corner)
|
||||
await handle.hover()
|
||||
const box = await handle.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
`Resize handle for corner "${corner}" has no bounding box`
|
||||
)
|
||||
}
|
||||
|
||||
const page = this.locator.page()
|
||||
const startX = box.x + box.width / 2
|
||||
const startY = box.y + box.height / 2
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX + deltaX, startY + deltaY, {
|
||||
steps: 5
|
||||
})
|
||||
await page.mouse.up()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-off.png',
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ChangeTrackerDebugState = {
|
||||
changeCount: number
|
||||
@@ -310,4 +311,28 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Tracks convert to subgraph as undo step',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
|
||||
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
|
||||
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
|
||||
|
||||
await input.fill('40')
|
||||
await node.title.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
|
||||
.click()
|
||||
await expect(input).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('40')
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -157,13 +157,6 @@ test.describe('Signin dialog', () => {
|
||||
})
|
||||
|
||||
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/customers', (route) =>
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'test-user-e2e', email: 'test@example.com' })
|
||||
})
|
||||
)
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
const { result: dialogResult } = await dialog.openWithResult()
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
const shareId = 'fe828-long-name'
|
||||
|
||||
// Unbroken, space-free name (mimics a content-hash workflow name) that cannot
|
||||
// wrap at whitespace and previously forced the dialog to scroll horizontally.
|
||||
const longWorkflowName =
|
||||
'c23df0133afe9cf61a9c0e3b1f5d8a7e6429bd14f0a3c8e2d9b7165430fedcba99887766554433221100ffeeddccbbaa'
|
||||
|
||||
const longNameWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: shareId,
|
||||
workflow_id: 'fe828-long-name-workflow',
|
||||
name: longWorkflowName,
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: []
|
||||
},
|
||||
assets: []
|
||||
}
|
||||
|
||||
async function mockLongNameSharedWorkflow(page: Page): Promise<void> {
|
||||
await page.route(`**/workflows/published/${shareId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(longNameWorkflowResponse)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
test.describe('Open shared workflow dialog', { tag: '@cloud' }, () => {
|
||||
test('wraps a long workflow name instead of scrolling horizontally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await mockLongNameSharedWorkflow(page)
|
||||
await comfyPage.setup({ clearStorage: false, url: `/?share=${shareId}` })
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
const heading = dialog.locator('main h2')
|
||||
await expect(heading).toHaveText(longWorkflowName)
|
||||
|
||||
const { scrollWidth, clientWidth } = await dialog.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth
|
||||
}))
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
|
||||
})
|
||||
})
|
||||
@@ -7,14 +7,9 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_NAME = 'group_node'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
|
||||
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -23,19 +18,22 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
const groupNodeCategory = 'group nodes>workflow'
|
||||
const groupNodeBookmarkName = `workflow>${groupNodeName}`
|
||||
let libraryTab: NodeLibrarySidebarTab
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
libraryTab = comfyPage.menu.nodeLibraryTab
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
@@ -43,8 +41,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
// Add group node from node library sidebar
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
@@ -53,9 +52,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
@@ -64,12 +63,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
.toEqual([groupNodeBookmarkName])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||
|
||||
// Unbookmark the node
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
@@ -96,57 +96,72 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
test(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(groupNodeName)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${groupNodeName}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const pos = await groupNode.getPosition()
|
||||
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const makeGroup = async (name: string, type1: string, type2: string) => {
|
||||
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
|
||||
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
|
||||
await node1.click('title')
|
||||
await node2.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
return await node2.convertToGroupNode(name)
|
||||
}
|
||||
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
const group1 = await makeGroup(
|
||||
'g1',
|
||||
'CLIPTextEncode',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
|
||||
|
||||
const manage1 = await group1.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
|
||||
await manage1.close()
|
||||
await expect(manage1.root).toBeHidden()
|
||||
|
||||
const manage2 = await group2.manageGroupNode()
|
||||
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
@@ -186,6 +201,42 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
|
||||
expect(nodes).toHaveLength(1)
|
||||
return nodes[0]
|
||||
}
|
||||
const latent = await expectSingleNode('EmptyLatentImage')
|
||||
const sampler = await expectSingleNode('KSampler')
|
||||
// Remove existing link
|
||||
const samplerInput = await sampler.getInput(0)
|
||||
await samplerInput.removeLinks()
|
||||
// Group latent + sampler
|
||||
await latent.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
await sampler.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
const groupNode = await sampler.convertToGroupNode()
|
||||
// Connect node to group
|
||||
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
||||
const input = await ckpt.connectOutput(0, groupNode, 0)
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
// Modify the group node via manage dialog
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await manage.selectNode('KSampler')
|
||||
await manage.changeTab('Inputs')
|
||||
await manage.setLabel('model', 'test')
|
||||
await manage.save()
|
||||
await manage.close()
|
||||
// Ensure the link is still present
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -198,6 +249,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.describe('Copy and paste', () => {
|
||||
let groupNode: NodeReference | null
|
||||
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_PREFIX = 'workflow>'
|
||||
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
|
||||
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
|
||||
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
@@ -226,10 +282,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
@@ -243,7 +299,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Copies and pastes group node after clearing workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set setting
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
// Clear workflow
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.clipboard.paste()
|
||||
@@ -283,6 +342,24 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Convert to group node, no selection', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
|
||||
@@ -166,15 +166,6 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB |
@@ -76,34 +76,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
})
|
||||
|
||||
test(
|
||||
'Middle-click drag should pan the mask editor canvas',
|
||||
{ tag: ['@canvas'] },
|
||||
async ({ comfyPage, comfyMouse, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
const getCanvasPosition = () =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const container = document.querySelector('#maskEditorCanvasContainer')
|
||||
if (!(container instanceof HTMLElement)) return null
|
||||
|
||||
return {
|
||||
left: container.style.left,
|
||||
top: container.style.top
|
||||
}
|
||||
})
|
||||
const canvasPositionBefore = await getCanvasPosition()
|
||||
|
||||
await comfyMouse.middleDragFromCenter(
|
||||
pointerZone,
|
||||
{ x: 140, y: 90 },
|
||||
{ steps: 10 }
|
||||
)
|
||||
|
||||
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test('undo reverts a brush stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
@@ -9,7 +9,7 @@ test.describe(
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 300 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
@@ -46,8 +46,15 @@ test.describe(
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
|
||||
@@ -233,21 +233,21 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
|
||||
await comfyPage.searchBox.addFilter('utilities', 'Category')
|
||||
await comfyPage.searchBox.addFilter('utils', 'Category')
|
||||
})
|
||||
|
||||
test('Can remove first filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
await expectFilterChips(comfyPage, ['CLIP', 'utilities'])
|
||||
await expectFilterChips(comfyPage, ['CLIP', 'utils'])
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
await expectFilterChips(comfyPage, ['utilities'])
|
||||
await expectFilterChips(comfyPage, ['utils'])
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
await expectFilterChips(comfyPage, [])
|
||||
})
|
||||
|
||||
test('Can remove middle filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.removeFilter(1)
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'utilities'])
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'utils'])
|
||||
})
|
||||
|
||||
test('Can remove last filter', async ({ comfyPage }) => {
|
||||
|
||||
@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -3,40 +3,36 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test(
|
||||
'Price badge displays on subgraphs',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
}
|
||||
)
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
@@ -35,6 +35,23 @@ test.describe(
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.canvasOps.rightClick()
|
||||
await comfyPage.contextMenu.clickMenuItem(
|
||||
'Convert to Group Node (Deprecated)'
|
||||
)
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 103 KiB |
@@ -56,34 +56,6 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
await expect(nodeRef).not.toBeCollapsed()
|
||||
})
|
||||
|
||||
test('More Options menu does not surface duplicate LiteGraph Resize / Collapse / Expand entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
await expect(
|
||||
menu.getByText('Minimize Node', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Resize', exact: true })
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Collapse', exact: true })
|
||||
).toHaveCount(0)
|
||||
|
||||
await menu.getByText('Minimize Node', { exact: true }).click()
|
||||
await openMoreOptions(comfyPage)
|
||||
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Expand', exact: true })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('copy via More Options menu', async ({ comfyPage }) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
|
||||
101
browser_tests/tests/sidebar/assets-output-dedupe.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
/**
|
||||
* Expanded folder view must drop output records that resolve to the same
|
||||
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
|
||||
* v-for in VirtualGrid collides and one asset visibly duplicates its
|
||||
* neighbours while scrolling.
|
||||
*/
|
||||
|
||||
const STACK_JOB_ID = 'job-output-dedupe'
|
||||
const COVER_NODE_ID = '9'
|
||||
const COVER_FILENAME = 'cover_00001_.png'
|
||||
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
|
||||
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
|
||||
|
||||
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
|
||||
// 4 unique composite keys expected after dedupe.
|
||||
const STACK_JOB_OUTPUTS = [
|
||||
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
|
||||
...DISTINCT_FILENAMES.map((filename) => ({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output' as const
|
||||
})),
|
||||
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
|
||||
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
|
||||
]
|
||||
|
||||
const STACK_JOB = createMockJob({
|
||||
id: STACK_JOB_ID,
|
||||
create_time: 5000,
|
||||
execution_start_time: 5000,
|
||||
execution_end_time: 5050,
|
||||
preview_output: {
|
||||
filename: COVER_FILENAME,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: COVER_NODE_ID,
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: STACK_JOB_OUTPUTS.length
|
||||
})
|
||||
|
||||
const STACK_JOB_DETAIL: JobDetail = {
|
||||
...STACK_JOB,
|
||||
outputs: {
|
||||
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
|
||||
}
|
||||
}
|
||||
|
||||
const EXPECTED_TOTAL_TILES = 4
|
||||
|
||||
test.describe(
|
||||
'Expanded folder view dedupes duplicate composite output keys',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
// @cloud comfyPage already navigates with Firebase auth seeded; a second
|
||||
// setup() call would clear localStorage and bounce to /cloud/login.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([STACK_JOB])
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('renders one tile per unique composite key', async ({
|
||||
comfyPage
|
||||
}, testInfo) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
|
||||
|
||||
const labels = await tab.assetCards.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((el) => el.getAttribute('aria-label'))
|
||||
.filter((v): v is string => v !== null)
|
||||
)
|
||||
expect(new Set(labels).size).toBe(labels.length)
|
||||
|
||||
await testInfo.attach('expanded-folder-view.png', {
|
||||
body: await comfyPage.page.screenshot({ fullPage: false }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||