Compare commits
5 Commits
main
...
test/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bdeaa663c | ||
|
|
337f95c81d | ||
|
|
d887dcb5ed | ||
|
|
40f311faf1 | ||
|
|
afdd0af8b3 |
@@ -1,246 +0,0 @@
|
||||
---
|
||||
name: hardening-flaky-e2e-tests
|
||||
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
|
||||
---
|
||||
|
||||
# Hardening Flaky E2E Tests
|
||||
|
||||
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather CI Evidence
|
||||
|
||||
```bash
|
||||
gh run list --workflow=ci-test.yaml --limit=5
|
||||
gh run download <run-id> -n playwright-report
|
||||
```
|
||||
|
||||
- Open `report.json` and search for `"status": "flaky"` entries.
|
||||
- Collect file paths, test titles, and error messages.
|
||||
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
|
||||
- Use `error-context.md`, traces, and page snapshots before editing code.
|
||||
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
|
||||
|
||||
### 2. Classify the Flake
|
||||
|
||||
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
|
||||
|
||||
| # | Pattern | Signature in Code | Fix |
|
||||
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
|
||||
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
|
||||
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
|
||||
|
||||
### 3. Apply the Transform
|
||||
|
||||
#### Rule: Choose the Smallest Correct Assertion
|
||||
|
||||
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
|
||||
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
|
||||
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
|
||||
- **Never** use `waitForTimeout()` to hide a race.
|
||||
|
||||
```typescript
|
||||
// ✅ Single value — use expect.poll
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
|
||||
.toBe(3)
|
||||
|
||||
// ✅ Locator count — use toHaveCount
|
||||
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
|
||||
|
||||
// ✅ Multiple conditions — use toPass
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
#### Rule: Wait for the Real Readiness Boundary
|
||||
|
||||
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
|
||||
|
||||
Common readiness boundaries:
|
||||
|
||||
| After this action... | Wait for... |
|
||||
| -------------------------------------- | ------------------------------------------------------------ |
|
||||
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
|
||||
| Menu item click | `await contextMenu.waitForHidden()` |
|
||||
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
|
||||
| Settings write | Poll the setting value with `expect.poll()` |
|
||||
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
|
||||
| Graph mutation (add/remove node, link) | Poll link/node count |
|
||||
| Clipboard write | Poll pasted value |
|
||||
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
|
||||
|
||||
#### Rule: Expose Locators for Retrying Assertions
|
||||
|
||||
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
|
||||
|
||||
```typescript
|
||||
// Helper exposes locator
|
||||
get domWidgets(): Locator {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
// Caller uses retrying assertion
|
||||
await expect(comfyPage.domWidgets).toHaveCount(2)
|
||||
```
|
||||
|
||||
Replace count methods with locator getters so callers can use retrying assertions directly.
|
||||
|
||||
#### Rule: Fix Check-then-Act Races in Helpers
|
||||
|
||||
```typescript
|
||||
// ❌ Race: count can change between check and waitFor
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
// ✅ Direct: waitFor handles both cases
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
```
|
||||
|
||||
#### Rule: Remove force:true from Clicks
|
||||
|
||||
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
|
||||
|
||||
```typescript
|
||||
// ❌ Hides the race
|
||||
await closeButton.click({ force: true })
|
||||
|
||||
// ✅ Surfaces the real issue — fix with proper wait
|
||||
await closeButton.click()
|
||||
await dialog.waitForHidden()
|
||||
```
|
||||
|
||||
#### Rule: Handle Non-deterministic Element Order
|
||||
|
||||
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
|
||||
|
||||
```typescript
|
||||
// ❌ Assumes order
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
|
||||
// ✅ Find by ID or proximity
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
let target = nodes[0]
|
||||
for (const n of nodes) {
|
||||
const pos = await n.getPosition()
|
||||
if (Math.abs(pos.y - expectedY) < minDist) target = n
|
||||
}
|
||||
```
|
||||
|
||||
Or guard the assumption:
|
||||
|
||||
```typescript
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes).toHaveLength(1)
|
||||
const node = nodes[0]
|
||||
```
|
||||
|
||||
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
|
||||
|
||||
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
|
||||
|
||||
```typescript
|
||||
// ✅ Retry click+assert together
|
||||
await expect(async () => {
|
||||
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
|
||||
await expect(dialog).toBeHidden({ timeout: 500 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
### 4. Keep Changes Narrow
|
||||
|
||||
- Shared helpers should drive setup to a stable boundary.
|
||||
- Do not encode one-spec timing assumptions into generic helpers.
|
||||
- If a race only matters to one spec, prefer a local wait in that spec.
|
||||
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
|
||||
|
||||
### 5. Verify Narrowly
|
||||
|
||||
```bash
|
||||
# Targeted rerun with repetition
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
|
||||
|
||||
# Single test by line number (avoids grep quoting issues on Windows)
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
|
||||
```
|
||||
|
||||
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
|
||||
- Verify with the smallest command that exercises the flaky path.
|
||||
|
||||
### 6. Watch CI E2E Runs
|
||||
|
||||
After pushing, use `gh` to monitor the E2E workflow:
|
||||
|
||||
```bash
|
||||
# Find the run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
|
||||
|
||||
# Watch it live (blocks until complete, streams logs)
|
||||
gh run watch <run-id>
|
||||
|
||||
# One-liner: find and watch the latest E2E run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
# One-liner equivalent
|
||||
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
|
||||
```
|
||||
|
||||
After the run completes:
|
||||
|
||||
```bash
|
||||
# Download the Playwright report artifact
|
||||
gh run download <run-id> -n playwright-report
|
||||
|
||||
# View the run summary in browser
|
||||
gh run view <run-id> --web
|
||||
```
|
||||
|
||||
Also watch the unit test workflow in parallel if you changed helpers:
|
||||
|
||||
```bash
|
||||
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
|
||||
```
|
||||
|
||||
### 7. Pre-merge Checklist
|
||||
|
||||
Before merging a flaky-test fix, confirm:
|
||||
|
||||
- [ ] The latest CI artifact was inspected directly
|
||||
- [ ] The root cause is stated as a race or readiness mismatch
|
||||
- [ ] The fix waits on the real readiness boundary
|
||||
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
|
||||
- [ ] The fix stays local unless a shared helper truly owns the race
|
||||
- [ ] Local verification uses a targeted rerun
|
||||
- [ ] No behavioral changes to the test — only timing/retry strategy updated
|
||||
|
||||
## Local Noise — Do Not Fix
|
||||
|
||||
These are local distractions, not CI root causes:
|
||||
|
||||
- Missing local input fixture files required by the test path
|
||||
- Missing local models directory
|
||||
- Teardown `EPERM` while restoring the local browser-test user data directory
|
||||
- Local screenshot baseline differences on Windows
|
||||
|
||||
Rules:
|
||||
|
||||
- First confirm whether it blocks the exact flaky path under investigation.
|
||||
- Do not commit temporary local assets used only for verification.
|
||||
- Do not commit local screenshot baselines.
|
||||
16
.github/workflows/release-version-bump.yaml
vendored
@@ -142,22 +142,10 @@ jobs:
|
||||
fi
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Ensure packageManager field exists
|
||||
run: |
|
||||
if ! grep -q '"packageManager"' package.json; then
|
||||
# Old branches (e.g. core/1.42) predate the packageManager field.
|
||||
# Inject it so pnpm/action-setup can resolve the version.
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
|
||||
pkg.packageManager = 'pnpm@10.33.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
echo "Injected packageManager into package.json for legacy branch"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
@@ -105,7 +104,8 @@
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -179,12 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
|
||||
## Design Standards
|
||||
|
||||
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
|
||||
|
||||
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/\*.md for detailed patterns.
|
||||
@@ -232,7 +226,6 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- shadcn/vue: <https://www.shadcn-vue.com/>
|
||||
- Reka UI: <https://reka-ui.com/>
|
||||
- PrimeVue: <https://primevue.org>
|
||||
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
|
||||
@@ -62,37 +62,6 @@ python main.py --port 8188 --cpu
|
||||
- Run `pnpm dev:electron` to start the dev server with electron API mocked
|
||||
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
|
||||
|
||||
#### Testing with Cloud & Staging Environments
|
||||
|
||||
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
|
||||
|
||||
**Option 1: Frontend — `pnpm dev:cloud`**
|
||||
|
||||
The simplest approach. This proxies all API requests to the test cloud environment:
|
||||
|
||||
```bash
|
||||
pnpm dev:cloud
|
||||
```
|
||||
|
||||
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
|
||||
```
|
||||
|
||||
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
|
||||
|
||||
**Option 2: Backend — `--comfy-api-base`**
|
||||
|
||||
Alternatively, launch the ComfyUI backend pointed at the staging API:
|
||||
|
||||
```bash
|
||||
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
|
||||
```
|
||||
|
||||
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
|
||||
|
||||
#### Access dev server on touch devices
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = computed(() => [
|
||||
{ icon: '📚', label: t('academy.tutorials', locale) },
|
||||
{ icon: '🎥', label: t('academy.videos', locale) },
|
||||
{ icon: '🛠️', label: t('academy.projects', locale) }
|
||||
])
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,15 +13,14 @@ const features = computed(() => [
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
{{ t('academy.badge', locale) }}
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">
|
||||
{{ t('academy.heading', locale) }}
|
||||
</h2>
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
{{ t('academy.body', locale) }}
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
@@ -47,7 +40,7 @@ const features = computed(() => [
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('academy.cta', locale) }}
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards = computed(() => [
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: t('cta.desktop.title', locale),
|
||||
description: t('cta.desktop.desc', locale),
|
||||
cta: t('cta.desktop.cta', locale),
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: t('cta.cloud.title', locale),
|
||||
description: t('cta.cloud.desc', locale),
|
||||
cta: t('cta.cloud.cta', locale),
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: t('cta.api.title', locale),
|
||||
description: t('cta.api.desc', locale),
|
||||
cta: t('cta.api.cta', locale),
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
{{ t('cta.heading', locale) }}
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps = computed(() => [
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: t('getStarted.step1.title', locale),
|
||||
description: t('getStarted.step1.desc', locale)
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: t('getStarted.step2.title', locale),
|
||||
description: t('getStarted.step2.desc', locale)
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: t('getStarted.step3.title', locale),
|
||||
description: t('getStarted.step3.desc', locale)
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('getStarted.heading', locale) }}
|
||||
</h2>
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
{{ t('getStarted.subheading', locale) }}
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
@@ -62,7 +55,7 @@ const steps = computed(() => [
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('getStarted.cta', locale) }}
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctaButtons = computed(() => [
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: t('hero.cta.getStarted', locale),
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: t('hero.cta.learnMore', locale),
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,11 +39,12 @@ const ctaButtons = computed(() => [
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
{{ t('hero.headline', locale) }}
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
{{ t('hero.subheadline', locale) }}
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
@@ -14,11 +7,13 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
{{ t('manifesto.heading', locale) }}
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
{{ t('manifesto.body', locale) }}
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = computed(() => [
|
||||
t('showcase.nodeEditor', locale),
|
||||
t('showcase.realTimePreview', locale),
|
||||
t('showcase.versionControl', locale)
|
||||
])
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,11 +8,9 @@ const features = computed(() => [
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('showcase.heading', locale) }}
|
||||
</h2>
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
{{ t('showcase.subheading', locale) }}
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -40,9 +28,7 @@ const features = computed(() => [
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ t('showcase.placeholder', locale) }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,73 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { localePath, t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const columns = computed(() => [
|
||||
const columns = [
|
||||
{
|
||||
title: t('footer.product', locale),
|
||||
title: 'Product',
|
||||
links: [
|
||||
{
|
||||
label: t('footer.comfyDesktop', locale),
|
||||
href: localePath('/download', locale)
|
||||
},
|
||||
{ label: t('footer.comfyCloud', locale), href: 'https://app.comfy.org' },
|
||||
{ label: t('footer.comfyHub', locale), href: 'https://hub.comfy.org' },
|
||||
{
|
||||
label: t('footer.pricing', locale),
|
||||
href: localePath('/pricing', locale)
|
||||
}
|
||||
{ label: 'Comfy Desktop', href: '/download' },
|
||||
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
|
||||
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
|
||||
{ label: 'Pricing', href: '/pricing' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{
|
||||
label: t('footer.documentation', locale),
|
||||
href: 'https://docs.comfy.org'
|
||||
},
|
||||
{ label: t('footer.blog', locale), href: 'https://blog.comfy.org' },
|
||||
{
|
||||
label: t('footer.gallery', locale),
|
||||
href: localePath('/gallery', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.github', locale),
|
||||
href: 'https://github.com/comfyanonymous/ComfyUI'
|
||||
}
|
||||
{ label: 'Documentation', href: 'https://docs.comfy.org' },
|
||||
{ label: 'Blog', href: 'https://blog.comfy.org' },
|
||||
{ label: 'Gallery', href: '/gallery' },
|
||||
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.company', locale),
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: localePath('/about', locale) },
|
||||
{
|
||||
label: t('footer.careers', locale),
|
||||
href: localePath('/careers', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.enterprise', locale),
|
||||
href: localePath('/enterprise', locale)
|
||||
}
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.legal', locale),
|
||||
title: 'Legal',
|
||||
links: [
|
||||
{
|
||||
label: t('footer.terms', locale),
|
||||
href: localePath('/terms-of-service', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.privacy', locale),
|
||||
href: localePath('/privacy-policy', locale)
|
||||
}
|
||||
{ label: 'Terms of Service', href: '/terms-of-service' },
|
||||
{ label: 'Privacy Policy', href: '/privacy-policy' }
|
||||
]
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
const socials = [
|
||||
{
|
||||
@@ -110,16 +76,11 @@ const socials = [
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="lg:col-span-1">
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<a
|
||||
:href="localePath('/', locale)"
|
||||
class="text-2xl font-bold text-brand-yellow italic"
|
||||
>
|
||||
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
|
||||
Comfy
|
||||
</a>
|
||||
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||
<p class="mt-4 text-sm text-smoke-700">
|
||||
{{ t('footer.tagline', locale) }}
|
||||
Professional control of visual AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,8 +113,7 @@ const socials = [
|
||||
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
© {{ new Date().getFullYear() }}
|
||||
{{ t('footer.copyright', locale) }}
|
||||
© {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
|
||||
</p>
|
||||
|
||||
<!-- Social icons -->
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { localePath, t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const currentPath = ref('')
|
||||
|
||||
const navLinks = computed(() => [
|
||||
{
|
||||
label: t('nav.enterprise', locale),
|
||||
href: localePath('/enterprise', locale)
|
||||
},
|
||||
{ label: t('nav.gallery', locale), href: localePath('/gallery', locale) },
|
||||
{ label: t('nav.about', locale), href: localePath('/about', locale) },
|
||||
{ label: t('nav.careers', locale), href: localePath('/careers', locale) }
|
||||
])
|
||||
const navLinks = [
|
||||
{ label: 'ENTERPRISE', href: '/enterprise' },
|
||||
{ label: 'GALLERY', href: '/gallery' },
|
||||
{ label: 'ABOUT', href: '/about' },
|
||||
{ label: 'CAREERS', href: '/careers' }
|
||||
]
|
||||
|
||||
const ctaLinks = [
|
||||
{
|
||||
@@ -57,19 +49,14 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
:aria-label="t('nav.ariaLabel', locale)"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<!-- Logo -->
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<a
|
||||
:href="localePath('/', locale)"
|
||||
class="text-2xl font-bold text-brand-yellow italic"
|
||||
>
|
||||
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
|
||||
Comfy
|
||||
</a>
|
||||
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div class="hidden items-center gap-8 md:flex">
|
||||
@@ -90,8 +77,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
@@ -103,7 +90,7 @@ onUnmounted(() => {
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
class="flex flex-col gap-1.5 md:hidden"
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
aria-label="Toggle menu"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
@@ -148,8 +135,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
@@ -20,11 +14,11 @@ const logos = [
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ value: '60K+', label: t('social.customNodes', locale) },
|
||||
{ value: '106K+', label: t('social.githubStars', locale) },
|
||||
{ value: '500K+', label: t('social.communityMembers', locale) }
|
||||
])
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +28,7 @@ const metrics = computed(() => [
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
{{ t('social.heading', locale) }}
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const industryKeys = [
|
||||
'All',
|
||||
'VFX',
|
||||
'Gaming',
|
||||
'Advertising',
|
||||
'Photography'
|
||||
] as const
|
||||
|
||||
const industryLabels = computed(() => ({
|
||||
All: t('testimonials.all', locale),
|
||||
VFX: t('testimonials.vfx', locale),
|
||||
Gaming: t('testimonials.gaming', locale),
|
||||
Advertising: t('testimonials.advertising', locale),
|
||||
Photography: t('testimonials.photography', locale)
|
||||
}))
|
||||
|
||||
const activeFilter = ref<(typeof industryKeys)[number]>('All')
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
@@ -31,7 +12,7 @@ const testimonials = [
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX' as const
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
@@ -39,7 +20,7 @@ const testimonials = [
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming' as const
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
@@ -47,7 +28,7 @@ const testimonials = [
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising' as const
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -61,13 +42,13 @@ const filteredTestimonials = computed(() => {
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
{{ t('testimonials.heading', locale) }}
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industryKeys"
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
type="button"
|
||||
:aria-pressed="activeFilter === industry"
|
||||
@@ -79,7 +60,7 @@ const filteredTestimonials = computed(() => {
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industryLabels[industry] }}
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +85,7 @@ const filteredTestimonials = computed(() => {
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const categories = computed(() => [
|
||||
t('useCase.vfx', locale),
|
||||
t('useCase.agencies', locale),
|
||||
t('useCase.gaming', locale),
|
||||
t('useCase.ecommerce', locale),
|
||||
t('useCase.community', locale)
|
||||
])
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
@@ -31,7 +27,7 @@ const activeCategory = ref(0)
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('useCase.heading', locale) }}
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
@@ -56,14 +52,15 @@ const activeCategory = ref(0)
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
{{ t('useCase.body', locale) }}
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
{{ t('useCase.cta', locale) }}
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const pillars = computed(() => [
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: t('pillars.buildTitle', locale),
|
||||
description: t('pillars.buildDesc', locale)
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: t('pillars.customizeTitle', locale),
|
||||
description: t('pillars.customizeDesc', locale)
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: t('pillars.refineTitle', locale),
|
||||
description: t('pillars.refineDesc', locale)
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: t('pillars.automateTitle', locale),
|
||||
description: t('pillars.automateDesc', locale)
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: t('pillars.runTitle', locale),
|
||||
description: t('pillars.runDesc', locale)
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,10 +36,10 @@ const pillars = computed(() => [
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
{{ t('pillars.heading', locale) }}
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
{{ t('pillars.subheading', locale) }}
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const translations = {
|
||||
// HeroSection
|
||||
'hero.headline': {
|
||||
en: 'Professional Control of Visual AI',
|
||||
'zh-CN': '视觉 AI 的专业控制'
|
||||
},
|
||||
'hero.subheadline': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
'hero.cta.getStarted': { en: 'GET STARTED', 'zh-CN': '立即开始' },
|
||||
'hero.cta.learnMore': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
|
||||
// SocialProofBar
|
||||
'social.heading': {
|
||||
en: 'Trusted by Industry Leaders',
|
||||
'zh-CN': '受到行业领导者的信赖'
|
||||
},
|
||||
'social.customNodes': { en: 'Custom Nodes', 'zh-CN': '自定义节点' },
|
||||
'social.githubStars': { en: 'GitHub Stars', 'zh-CN': 'GitHub 星标' },
|
||||
'social.communityMembers': {
|
||||
en: 'Community Members',
|
||||
'zh-CN': '社区成员'
|
||||
},
|
||||
|
||||
// ProductShowcase
|
||||
'showcase.heading': { en: 'See Comfy in Action', 'zh-CN': '观看 Comfy 实战' },
|
||||
'showcase.subheading': {
|
||||
en: 'Watch how professionals build AI workflows with unprecedented control',
|
||||
'zh-CN': '观看专业人士如何以前所未有的控制力构建 AI 工作流'
|
||||
},
|
||||
'showcase.placeholder': {
|
||||
en: 'Workflow Demo Coming Soon',
|
||||
'zh-CN': '工作流演示即将推出'
|
||||
},
|
||||
'showcase.nodeEditor': { en: 'Node-Based Editor', 'zh-CN': '节点编辑器' },
|
||||
'showcase.realTimePreview': {
|
||||
en: 'Real-Time Preview',
|
||||
'zh-CN': '实时预览'
|
||||
},
|
||||
'showcase.versionControl': {
|
||||
en: 'Version Control',
|
||||
'zh-CN': '版本控制'
|
||||
},
|
||||
|
||||
// ValuePillars
|
||||
'pillars.heading': {
|
||||
en: 'The Building Blocks of AI Production',
|
||||
'zh-CN': 'AI 制作的基本要素'
|
||||
},
|
||||
'pillars.subheading': {
|
||||
en: 'Five powerful capabilities that give you complete control',
|
||||
'zh-CN': '五大强大功能,让您完全掌控'
|
||||
},
|
||||
'pillars.buildTitle': { en: 'Build', 'zh-CN': '构建' },
|
||||
'pillars.buildDesc': {
|
||||
en: 'Design complex AI workflows visually with our node-based editor',
|
||||
'zh-CN': '使用节点编辑器直观地设计复杂的 AI 工作流'
|
||||
},
|
||||
'pillars.customizeTitle': { en: 'Customize', 'zh-CN': '自定义' },
|
||||
'pillars.customizeDesc': {
|
||||
en: 'Fine-tune every parameter across any model architecture',
|
||||
'zh-CN': '在任何模型架构中微调每个参数'
|
||||
},
|
||||
'pillars.refineTitle': { en: 'Refine', 'zh-CN': '优化' },
|
||||
'pillars.refineDesc': {
|
||||
en: 'Iterate on outputs with precision controls and real-time preview',
|
||||
'zh-CN': '通过精确控制和实时预览迭代输出'
|
||||
},
|
||||
'pillars.automateTitle': { en: 'Automate', 'zh-CN': '自动化' },
|
||||
'pillars.automateDesc': {
|
||||
en: 'Scale your workflows with batch processing and API integration',
|
||||
'zh-CN': '通过批处理和 API 集成扩展工作流'
|
||||
},
|
||||
'pillars.runTitle': { en: 'Run', 'zh-CN': '运行' },
|
||||
'pillars.runDesc': {
|
||||
en: 'Deploy locally or in the cloud with identical results',
|
||||
'zh-CN': '在本地或云端部署,获得相同的结果'
|
||||
},
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.heading': {
|
||||
en: 'Built for Every Creative Industry',
|
||||
'zh-CN': '为每个创意行业而生'
|
||||
},
|
||||
'useCase.vfx': { en: 'VFX & Animation', 'zh-CN': '视觉特效与动画' },
|
||||
'useCase.agencies': { en: 'Creative Agencies', 'zh-CN': '创意机构' },
|
||||
'useCase.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
|
||||
'useCase.ecommerce': {
|
||||
en: 'eCommerce & Fashion',
|
||||
'zh-CN': '电商与时尚'
|
||||
},
|
||||
'useCase.community': {
|
||||
en: 'Community & Hobbyists',
|
||||
'zh-CN': '社区与爱好者'
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows, and a community that builds faster than any one company could.',
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': { en: 'EXPLORE WORKFLOWS', 'zh-CN': '探索工作流' },
|
||||
|
||||
// CaseStudySpotlight
|
||||
'caseStudy.heading': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'caseStudy.subheading': {
|
||||
en: 'See how leading studios use Comfy in production',
|
||||
'zh-CN': '了解领先工作室如何在生产中使用 Comfy'
|
||||
},
|
||||
'caseStudy.readMore': { en: 'READ CASE STUDY', 'zh-CN': '阅读案例' },
|
||||
|
||||
// TestimonialsSection
|
||||
'testimonials.heading': {
|
||||
en: 'What Professionals Say',
|
||||
'zh-CN': '专业人士的评价'
|
||||
},
|
||||
'testimonials.all': { en: 'All', 'zh-CN': '全部' },
|
||||
'testimonials.vfx': { en: 'VFX', 'zh-CN': '特效' },
|
||||
'testimonials.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
|
||||
'testimonials.advertising': { en: 'Advertising', 'zh-CN': '广告' },
|
||||
'testimonials.photography': { en: 'Photography', 'zh-CN': '摄影' },
|
||||
|
||||
// GetStartedSection
|
||||
'getStarted.heading': {
|
||||
en: 'Get Started in Minutes',
|
||||
'zh-CN': '几分钟即可开始'
|
||||
},
|
||||
'getStarted.subheading': {
|
||||
en: 'From download to your first AI-generated output in three simple steps',
|
||||
'zh-CN': '从下载到首次 AI 生成输出,只需三个简单步骤'
|
||||
},
|
||||
'getStarted.step1.title': {
|
||||
en: 'Download & Sign Up',
|
||||
'zh-CN': '下载与注册'
|
||||
},
|
||||
'getStarted.step1.desc': {
|
||||
en: 'Get Comfy Desktop for free or create a Cloud account',
|
||||
'zh-CN': '免费获取 Comfy Desktop 或创建云端账号'
|
||||
},
|
||||
'getStarted.step2.title': {
|
||||
en: 'Load a Workflow',
|
||||
'zh-CN': '加载工作流'
|
||||
},
|
||||
'getStarted.step2.desc': {
|
||||
en: 'Choose from thousands of community workflows or build your own',
|
||||
'zh-CN': '从数千个社区工作流中选择,或自行构建'
|
||||
},
|
||||
'getStarted.step3.title': { en: 'Generate', 'zh-CN': '生成' },
|
||||
'getStarted.step3.desc': {
|
||||
en: 'Hit run and watch your AI workflow come to life',
|
||||
'zh-CN': '点击运行,观看 AI 工作流生动呈现'
|
||||
},
|
||||
'getStarted.cta': { en: 'DOWNLOAD COMFY', 'zh-CN': '下载 COMFY' },
|
||||
|
||||
// CTASection
|
||||
'cta.heading': {
|
||||
en: 'Choose Your Way to Comfy',
|
||||
'zh-CN': '选择您的 Comfy 方式'
|
||||
},
|
||||
'cta.desktop.title': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
|
||||
'cta.desktop.desc': {
|
||||
en: 'Full power on your local machine. Free and open source.',
|
||||
'zh-CN': '在本地机器上释放全部性能。免费开源。'
|
||||
},
|
||||
'cta.desktop.cta': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
'cta.cloud.title': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'cta.cloud.desc': {
|
||||
en: 'Run workflows in the cloud. No GPU required.',
|
||||
'zh-CN': '在云端运行工作流,无需 GPU。'
|
||||
},
|
||||
'cta.cloud.cta': { en: 'TRY CLOUD', 'zh-CN': '试用云端' },
|
||||
'cta.api.title': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
'cta.api.desc': {
|
||||
en: 'Integrate AI generation into your applications.',
|
||||
'zh-CN': '将 AI 生成功能集成到您的应用程序中。'
|
||||
},
|
||||
'cta.api.cta': { en: 'VIEW DOCS', 'zh-CN': '查看文档' },
|
||||
|
||||
// ManifestoSection
|
||||
'manifesto.heading': { en: 'Method, Not Magic', 'zh-CN': '方法,而非魔法' },
|
||||
'manifesto.body': {
|
||||
en: 'We believe in giving creators real control over AI. Not black boxes. Not magic buttons. But transparent, reproducible, node-by-node control over every step of the creative process.',
|
||||
'zh-CN':
|
||||
'我们相信应赋予创作者对 AI 的真正控制权。没有黑箱,没有魔法按钮,而是对创作过程每一步的透明、可复现、逐节点控制。'
|
||||
},
|
||||
|
||||
// AcademySection
|
||||
'academy.badge': { en: 'COMFY ACADEMY', 'zh-CN': 'COMFY 学院' },
|
||||
'academy.heading': {
|
||||
en: 'Master AI Workflows',
|
||||
'zh-CN': '掌握 AI 工作流'
|
||||
},
|
||||
'academy.body': {
|
||||
en: 'Learn to build professional AI workflows with guided tutorials, video courses, and hands-on projects.',
|
||||
'zh-CN': '通过指导教程、视频课程和实践项目,学习构建专业的 AI 工作流。'
|
||||
},
|
||||
'academy.tutorials': { en: 'Guided Tutorials', 'zh-CN': '指导教程' },
|
||||
'academy.videos': { en: 'Video Courses', 'zh-CN': '视频课程' },
|
||||
'academy.projects': { en: 'Hands-on Projects', 'zh-CN': '实践项目' },
|
||||
'academy.cta': { en: 'EXPLORE ACADEMY', 'zh-CN': '探索学院' },
|
||||
|
||||
// SiteNav
|
||||
'nav.ariaLabel': { en: 'Main navigation', 'zh-CN': '主导航' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.enterprise': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'nav.gallery': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'nav.about': { en: 'ABOUT', 'zh-CN': '关于' },
|
||||
'nav.careers': { en: 'CAREERS', 'zh-CN': '招聘' },
|
||||
'nav.cloud': { en: 'COMFY CLOUD', 'zh-CN': 'COMFY 云端' },
|
||||
'nav.hub': { en: 'COMFY HUB', 'zh-CN': 'COMFY HUB' },
|
||||
|
||||
// SiteFooter
|
||||
'footer.tagline': {
|
||||
en: 'Professional control of visual AI.',
|
||||
'zh-CN': '视觉 AI 的专业控制。'
|
||||
},
|
||||
'footer.product': { en: 'Product', 'zh-CN': '产品' },
|
||||
'footer.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
'footer.company': { en: 'Company', 'zh-CN': '公司' },
|
||||
'footer.legal': { en: 'Legal', 'zh-CN': '法律' },
|
||||
'footer.copyright': {
|
||||
en: 'Comfy Org. All rights reserved.',
|
||||
'zh-CN': 'Comfy Org. 保留所有权利。'
|
||||
},
|
||||
'footer.comfyDesktop': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
|
||||
'footer.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'footer.comfyHub': { en: 'ComfyHub', 'zh-CN': 'ComfyHub' },
|
||||
'footer.pricing': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
'footer.documentation': { en: 'Documentation', 'zh-CN': '文档' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'footer.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'footer.about': { en: 'About', 'zh-CN': '关于' },
|
||||
'footer.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'footer.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
|
||||
'footer.terms': { en: 'Terms of Service', 'zh-CN': '服务条款' },
|
||||
'footer.privacy': { en: 'Privacy Policy', 'zh-CN': '隐私政策' }
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
|
||||
export function localePath(path: string, locale: Locale): string {
|
||||
return locale === 'en' ? path : `/${locale}${path}`
|
||||
}
|
||||
|
||||
export type { Locale }
|
||||
@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
|
||||
const team = [
|
||||
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
|
||||
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager 和 Impact/Inspire Pack 作者' },
|
||||
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
|
||||
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
|
||||
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
|
||||
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
|
||||
{ name: 'christian-byrne', role: '全栈开发工程师' },
|
||||
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved 和 Advanced-ControlNet 作者' },
|
||||
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
|
||||
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
|
||||
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
|
||||
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
|
||||
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
|
||||
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
|
||||
{ name: 'comfyanonymous', role: 'Creator of ComfyUI, cofounder' },
|
||||
{ name: 'Dr.Lt.Data', role: 'Creator of ComfyUI-Manager and Impact/Inspire Pack' },
|
||||
{ name: 'pythongosssss', role: 'Major contributor, creator of ComfyUI-Custom-Scripts' },
|
||||
{ name: 'yoland68', role: 'Creator of ComfyCLI, cofounder, ex-Google' },
|
||||
{ name: 'robinjhuang', role: 'Maintains Comfy Registry, cofounder, ex-Google Cloud' },
|
||||
{ name: 'jojodecay', role: 'ComfyUI event series host, community & partnerships' },
|
||||
{ name: 'christian-byrne', role: 'Fullstack developer' },
|
||||
{ name: 'Kosinkadink', role: 'Creator of AnimateDiff-Evolved and Advanced-ControlNet' },
|
||||
{ name: 'webfiltered', role: 'Overhauled Litegraph library' },
|
||||
{ name: 'Pablo', role: 'Product Design, ex-AI startup founder' },
|
||||
{ name: 'ComfyUI Wiki (Daxiong)', role: 'Official docs and templates' },
|
||||
{ name: 'ctrlbenlu (Ben)', role: 'Software engineer, ex-robotics' },
|
||||
{ name: 'Purz Beats', role: 'Motion graphics designer and ML Engineer' },
|
||||
{ name: 'Ricyu (Rich)', role: 'Software engineer, ex-Meta' },
|
||||
]
|
||||
|
||||
const collaborators = [
|
||||
{ name: 'Yogo', role: '协作者' },
|
||||
{ name: 'Fill (Machine Delusions)', role: '协作者' },
|
||||
{ name: 'Julien (MJM)', role: '协作者' },
|
||||
{ name: 'Yogo', role: 'Collaborator' },
|
||||
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
|
||||
{ name: 'Julien (MJM)', role: 'Collaborator' },
|
||||
]
|
||||
|
||||
const projects = [
|
||||
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
|
||||
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
|
||||
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
|
||||
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
|
||||
{ name: 'Docs', description: '官方文档、指南和教程。' },
|
||||
{ name: 'ComfyUI', description: 'The core node-based interface for generative AI workflows.' },
|
||||
{ name: 'ComfyUI Manager', description: 'Install, update, and manage custom nodes with one click.' },
|
||||
{ name: 'Comfy Registry', description: 'The official registry for publishing and discovering custom nodes.' },
|
||||
{ name: 'Frontends', description: 'The desktop and web frontends that power the ComfyUI experience.' },
|
||||
{ name: 'Docs', description: 'Official documentation, guides, and tutorials.' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'ComfyUI 免费吗?',
|
||||
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
|
||||
q: 'Is ComfyUI free?',
|
||||
a: 'Yes. ComfyUI is free and open-source under the GPL-3.0 license. You can use it for personal and commercial projects.',
|
||||
},
|
||||
{
|
||||
q: '谁在开发 ComfyUI?',
|
||||
a: 'ComfyUI 由 comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
|
||||
q: 'Who is behind ComfyUI?',
|
||||
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
|
||||
},
|
||||
{
|
||||
q: '如何参与贡献?',
|
||||
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
|
||||
q: 'How can I contribute?',
|
||||
a: 'Check out our GitHub repositories to report issues, submit pull requests, or build custom nodes. Join our Discord community to connect with other contributors.',
|
||||
},
|
||||
{
|
||||
q: '未来有什么计划?',
|
||||
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
|
||||
q: 'What are the future plans?',
|
||||
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<!-- 主页横幅 -->
|
||||
<!-- Hero -->
|
||||
<section class="px-6 pb-24 pt-40 text-center">
|
||||
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
|
||||
开创视觉与音频媒体的下一个前沿
|
||||
Crafting the next frontier of visual and audio media
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
|
||||
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
|
||||
An open-source community and company building the most powerful tools for generative AI creators.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 我们的使命 -->
|
||||
<!-- Our Mission -->
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
|
||||
<p class="mt-6 text-3xl font-bold md:text-4xl">
|
||||
我们想打造生成式 AI 的操作系统。
|
||||
We want to build the operating system for Gen AI.
|
||||
</p>
|
||||
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
|
||||
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
|
||||
从图像和视频合成到音频生成,ComfyUI 提供了一个模块化的
|
||||
节点式环境,让专业人士和爱好者可以创建、迭代
|
||||
和部署生产级工作流——没有黑箱。
|
||||
We're building the foundational tools that give creators full control over generative AI.
|
||||
From image and video synthesis to audio generation, ComfyUI provides a modular,
|
||||
node-based environment where professionals and enthusiasts can craft, iterate,
|
||||
and deploy production-quality workflows — without black boxes.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 我们做什么? -->
|
||||
<!-- What Do We Do? -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">What Do We Do?</h2>
|
||||
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
|
||||
@@ -98,23 +98,24 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 我们是谁 -->
|
||||
<!-- Who We Are -->
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
|
||||
<h2 class="text-3xl font-bold md:text-4xl">Who We Are</h2>
|
||||
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
|
||||
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
|
||||
创作者、开发者和研究者社区。今天,Comfy Org 是一个位于旧金山的小型扁平化团队,
|
||||
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
|
||||
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
|
||||
ComfyUI started as a personal project by comfyanonymous and grew into a global community
|
||||
of creators, developers, and researchers. Today, Comfy Org is a small, flat team based in
|
||||
San Francisco, backed by investors who believe in open-source AI tooling. We work
|
||||
alongside an incredible community of contributors who build custom nodes, share workflows,
|
||||
and push the boundaries of what's possible with generative AI.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 团队 -->
|
||||
<!-- Team -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">Team</h2>
|
||||
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{team.map((member) => (
|
||||
<div class="rounded-xl border border-white/10 p-5 text-center">
|
||||
@@ -127,10 +128,10 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 协作者 -->
|
||||
<!-- Collaborators -->
|
||||
<section class="bg-charcoal-800 px-6 py-16">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="text-2xl font-bold">协作者</h2>
|
||||
<h2 class="text-2xl font-bold">Collaborators</h2>
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
{collaborators.map((person) => (
|
||||
<div class="text-center">
|
||||
@@ -142,10 +143,10 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 常见问题 -->
|
||||
<!-- FAQs -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">FAQs</h2>
|
||||
<div class="mt-12 space-y-10">
|
||||
{faqs.map((faq) => (
|
||||
<div>
|
||||
@@ -157,19 +158,19 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 加入我们 CTA -->
|
||||
<!-- Join Our Team CTA -->
|
||||
<section class="bg-charcoal-800 px-6 py-24 text-center">
|
||||
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
|
||||
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
|
||||
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
|
||||
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
|
||||
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
|
||||
</p>
|
||||
<a
|
||||
href="/careers"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
查看开放职位
|
||||
View Open Positions
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -78,7 +78,7 @@ const questions = [
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
>
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<!-- Hero -->
|
||||
<section class="px-6 pb-24 pt-40">
|
||||
@@ -196,5 +196,5 @@ const questions = [
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -32,7 +32,7 @@ const cards = [
|
||||
---
|
||||
|
||||
<BaseLayout title="下载 — Comfy">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
|
||||
<h1 class="text-4xl font-bold text-white md:text-5xl">
|
||||
下载 ComfyUI
|
||||
@@ -76,5 +76,5 @@ const cards = [
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="作品集 — Comfy">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="bg-black text-white">
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
|
||||
@@ -39,5 +39,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<SocialProofBar locale="zh-CN" />
|
||||
<ProductShowcase locale="zh-CN" />
|
||||
<ValuePillars locale="zh-CN" />
|
||||
<UseCaseSection locale="zh-CN" client:visible />
|
||||
<CaseStudySpotlight locale="zh-CN" />
|
||||
<TestimonialsSection locale="zh-CN" client:visible />
|
||||
<GetStartedSection locale="zh-CN" />
|
||||
<CTASection locale="zh-CN" />
|
||||
<ManifestoSection locale="zh-CN" />
|
||||
<AcademySection locale="zh-CN" />
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24">
|
||||
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
|
||||
<p class="mt-2 text-sm text-smoke-500">生效日期:2025年4月18日</p>
|
||||
@@ -229,5 +229,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
description="ComfyUI 及相关 Comfy 服务的服务条款。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
|
||||
<header class="mb-16">
|
||||
<h1 class="text-3xl font-bold text-white">服务条款</h1>
|
||||
@@ -216,5 +216,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -83,21 +83,6 @@ await expect
|
||||
a different reproduction pattern.
|
||||
- Verify with the smallest command that exercises the flaky path.
|
||||
|
||||
## 7. Common Flake Patterns
|
||||
|
||||
| Pattern | Bad | Fix |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width).toBe(w)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width)).toBe(w)` |
|
||||
| **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||
| **Fake readiness helper** | Helper that clicks but doesn't assert state | Remove; poll the actual value |
|
||||
| **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||
| **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000ms; prefer default (5000ms) |
|
||||
| **Immediate count()** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||
| **Immediate evaluate after mutation** | `setSetting(); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||
| **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||
|
||||
## Current Local Noise
|
||||
|
||||
These are local distractions, not automatic CI root causes:
|
||||
|
||||
@@ -210,8 +210,8 @@ Most common testing needs are already addressed by these helpers, which will mak
|
||||
|
||||
```typescript
|
||||
// Prefer this:
|
||||
await expect.poll(() => node.isPinned()).toBe(true)
|
||||
await expect.poll(() => node.getProperty('title')).toBe('Expected Title')
|
||||
expect(await node.isPinned()).toBe(true)
|
||||
expect(await node.getProperty('title')).toBe('Expected Title')
|
||||
|
||||
// Over this - only use when needed:
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Blender 5.2.0 Alpha
|
||||
# www.blender.org
|
||||
mtllib Untitled.mtl
|
||||
o Cube
|
||||
v 2.857396 2.486626 -0.081892
|
||||
v 2.857396 0.486626 -0.081892
|
||||
v 2.857396 2.486626 1.918108
|
||||
v 2.857396 0.486626 1.918108
|
||||
v 0.857396 2.486626 -0.081892
|
||||
v 0.857396 0.486626 -0.081892
|
||||
v 0.857396 2.486626 1.918108
|
||||
v 0.857396 0.486626 1.918108
|
||||
vn -0.0000 1.0000 -0.0000
|
||||
vn -0.0000 -0.0000 1.0000
|
||||
vn -1.0000 -0.0000 -0.0000
|
||||
vn -0.0000 -1.0000 -0.0000
|
||||
vn 1.0000 -0.0000 -0.0000
|
||||
vn -0.0000 -0.0000 -1.0000
|
||||
vt 0.625000 0.500000
|
||||
vt 0.875000 0.500000
|
||||
vt 0.875000 0.750000
|
||||
vt 0.625000 0.750000
|
||||
vt 0.375000 0.750000
|
||||
vt 0.625000 1.000000
|
||||
vt 0.375000 1.000000
|
||||
vt 0.375000 0.000000
|
||||
vt 0.625000 0.000000
|
||||
vt 0.625000 0.250000
|
||||
vt 0.375000 0.250000
|
||||
vt 0.125000 0.500000
|
||||
vt 0.375000 0.500000
|
||||
vt 0.125000 0.750000
|
||||
s 0
|
||||
usemtl Material
|
||||
f 1/1/1 5/2/1 7/3/1 3/4/1
|
||||
f 4/5/2 3/4/2 7/6/2 8/7/2
|
||||
f 8/8/3 7/9/3 5/10/3 6/11/3
|
||||
f 6/12/4 2/13/4 4/5/4 8/14/4
|
||||
f 2/13/5 1/1/5 3/4/5 4/5/5
|
||||
f 6/11/6 5/10/6 1/1/6 2/13/6
|
||||
@@ -34,7 +34,6 @@ import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
@@ -182,7 +181,6 @@ export class ComfyPage {
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -234,7 +232,6 @@ export class ComfyPage {
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
@@ -392,8 +389,9 @@ export class ComfyPage {
|
||||
await modal.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
get domWidgets(): Locator {
|
||||
return this.page.locator('.dom-widget')
|
||||
/** Get number of DOM widgets on the canvas. */
|
||||
async getDOMWidgetCount() {
|
||||
return await this.page.locator('.dom-widget').count()
|
||||
}
|
||||
|
||||
async setFocusMode(focusMode: boolean) {
|
||||
@@ -446,10 +444,6 @@ export const comfyPageFixture = base.extend<{
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (testInfo.tags.includes('@cloud')) {
|
||||
await comfyPage.cloudAuth.mockAuth()
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const needsPerf =
|
||||
|
||||
@@ -48,6 +48,13 @@ export class VueNodeHelpers {
|
||||
return await this.nodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of selected Vue nodes
|
||||
*/
|
||||
async getSelectedNodeCount(): Promise<number> {
|
||||
return await this.selectedNodes.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Vue node IDs currently in the DOM
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,7 @@ export class BaseDialog {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.closeButton.click()
|
||||
await this.closeButton.click({ force: true })
|
||||
await this.waitForHidden()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,21 @@ export class ContextMenu {
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
|
||||
console.warn(
|
||||
`[waitForHidden] ${menuName} waitFor failed:`,
|
||||
error.message
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.primeVueMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' })
|
||||
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
|
||||
waitIfExists(this.litegraphMenu, 'litegraphMenu')
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,14 +168,10 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
get activeWorkflowLabel(): Locator {
|
||||
return this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getActiveWorkflowName() {
|
||||
return await this.activeWorkflowLabel.innerText()
|
||||
return await this.root
|
||||
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
|
||||
.innerText()
|
||||
}
|
||||
|
||||
async getTopLevelSavedWorkflowNames() {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Mocks Firebase authentication for cloud E2E tests.
|
||||
*
|
||||
* The cloud build's router guard waits for Firebase `onAuthStateChanged`
|
||||
* to fire, then checks `getAuthHeader()`. In CI no Firebase project is
|
||||
* configured, so the user is never authenticated and the app redirects
|
||||
* to `/cloud/login`.
|
||||
*
|
||||
* This helper seeds Firebase's IndexedDB persistence layer with a mock
|
||||
* user and intercepts the Firebase REST APIs (securetoken, identitytoolkit)
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
*/
|
||||
async mockAuth(): Promise<void> {
|
||||
await this.seedFirebaseIndexedDB()
|
||||
await this.mockFirebaseEndpoints()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a lightweight same-origin page to seed Firebase's
|
||||
* IndexedDB persistence with a mock user. This ensures the data
|
||||
* is written before the app loads and Firebase reads it.
|
||||
*
|
||||
* Firebase auth uses `browserLocalPersistence` which stores data in
|
||||
* IndexedDB database `firebaseLocalStorageDb`, object store
|
||||
* `firebaseLocalStorage`, keyed by `firebase:authUser:<apiKey>:<appName>`.
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
uid: 'test-user-e2e',
|
||||
email: 'e2e@test.comfy.org',
|
||||
displayName: 'E2E Test User',
|
||||
emailVerified: true,
|
||||
isAnonymous: false,
|
||||
providerData: [
|
||||
{
|
||||
providerId: 'google.com',
|
||||
uid: 'test-user-e2e',
|
||||
displayName: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
phoneNumber: null,
|
||||
photoURL: null
|
||||
}
|
||||
],
|
||||
stsTokenManager: {
|
||||
refreshToken: 'mock-refresh-token',
|
||||
accessToken: 'mock-firebase-id-token',
|
||||
expirationTime: Date.now() + 60 * 60 * 1000
|
||||
},
|
||||
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
|
||||
appName: '[DEFAULT]'
|
||||
}
|
||||
|
||||
const DB_NAME = 'firebaseLocalStorageDb'
|
||||
const STORE_NAME = 'firebaseLocalStorage'
|
||||
const KEY = `firebase:authUser:${MOCK_USER_DATA.apiKey}:${MOCK_USER_DATA.appName}`
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.close()
|
||||
const upgradeReq = indexedDB.open(DB_NAME, db.version + 1)
|
||||
upgradeReq.onerror = () => reject(upgradeReq.error)
|
||||
upgradeReq.onupgradeneeded = () => {
|
||||
const upgradedDb = upgradeReq.result
|
||||
if (!upgradedDb.objectStoreNames.contains(STORE_NAME)) {
|
||||
upgradedDb.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
upgradeReq.onsuccess = () => {
|
||||
const upgradedDb = upgradeReq.result
|
||||
const tx = upgradedDb.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(
|
||||
{ fpiVersion: '1', value: MOCK_USER_DATA },
|
||||
KEY
|
||||
)
|
||||
tx.oncomplete = () => {
|
||||
upgradedDb.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(
|
||||
{ fpiVersion: '1', value: MOCK_USER_DATA },
|
||||
KEY
|
||||
)
|
||||
tx.oncomplete = () => {
|
||||
db.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept Firebase Auth REST API endpoints so the SDK can
|
||||
* "refresh" the mock user's token without real credentials.
|
||||
*/
|
||||
private async mockFirebaseEndpoints(): Promise<void> {
|
||||
await this.page.route('**/securetoken.googleapis.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: 'mock-access-token',
|
||||
expires_in: '3600',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'mock-refresh-token',
|
||||
id_token: 'mock-firebase-id-token',
|
||||
user_id: 'test-user-e2e',
|
||||
project_id: 'dreamboothy-dev'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await this.page.route('**/identitytoolkit.googleapis.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
kind: 'identitytoolkit#GetAccountInfoResponse',
|
||||
users: [
|
||||
{
|
||||
localId: 'test-user-e2e',
|
||||
email: 'e2e@test.comfy.org',
|
||||
displayName: 'E2E Test User',
|
||||
emailVerified: true,
|
||||
validSince: '0',
|
||||
lastLoginAt: String(Date.now()),
|
||||
createdAt: String(Date.now()),
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await this.page.route('**/__/auth/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html><body></body></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
|
||||
import { Load3DViewerHelper } from '@e2e/tests/load3d/Load3DViewerHelper'
|
||||
|
||||
export const load3dTest = comfyPageFixture.extend<{
|
||||
load3d: Load3DHelper
|
||||
}>({
|
||||
load3d: async ({ comfyPage }, use) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await use(new Load3DHelper(node))
|
||||
}
|
||||
})
|
||||
|
||||
export const load3dViewerTest = load3dTest.extend<{
|
||||
viewer: Load3DViewerHelper
|
||||
}>({
|
||||
viewer: async ({ comfyPage }, use) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Load3D.3DViewerEnable', true)
|
||||
await use(new Load3DViewerHelper(comfyPage.page))
|
||||
}
|
||||
})
|
||||
@@ -445,7 +445,7 @@ export class SubgraphHelper {
|
||||
await this.rightClickOutputSlot(slotName)
|
||||
}
|
||||
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await this.comfyPage.contextMenu.waitForHidden()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async findSubgraphNodeId(): Promise<string> {
|
||||
|
||||
@@ -8,8 +8,14 @@ export class ToastHelper {
|
||||
return this.page.locator('.p-toast-message:visible')
|
||||
}
|
||||
|
||||
get toastErrors(): Locator {
|
||||
return this.page.locator('.p-toast-message.p-toast-message-error')
|
||||
async getToastErrorCount(): Promise<number> {
|
||||
return await this.page
|
||||
.locator('.p-toast-message.p-toast-message-error')
|
||||
.count()
|
||||
}
|
||||
|
||||
async getVisibleToastCount(): Promise<number> {
|
||||
return await this.visibleToasts.count()
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0): Promise<void> {
|
||||
|
||||
@@ -63,8 +63,7 @@ export const TestIds = {
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel'
|
||||
missingMediaLocateButton: 'missing-media-locate-button'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -155,12 +154,6 @@ export const TestIds = {
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
loading: {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3dViewer: {
|
||||
sidebar: 'load3d-viewer-sidebar'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -191,5 +184,3 @@ export type TestIdValue =
|
||||
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
|
||||
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
|
||||
|
||||
@@ -26,12 +26,9 @@ export class ManageGroupNode {
|
||||
await this.footer.getByText('Close').click()
|
||||
}
|
||||
|
||||
get selectedNodeTypeSelect(): Locator {
|
||||
return this.header.locator('select').first()
|
||||
}
|
||||
|
||||
async getSelectedNodeType() {
|
||||
return await this.selectedNodeTypeSelect.inputValue()
|
||||
const select = this.header.locator('select').first()
|
||||
return await select.inputValue()
|
||||
}
|
||||
|
||||
async selectNode(name: string) {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
export interface PromotedWidgetSnapshot {
|
||||
proxyWidgets: PromotedWidgetEntry[]
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
@@ -11,7 +18,9 @@ function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
export function normalizePromotedWidgets(
|
||||
value: unknown
|
||||
): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
@@ -22,29 +31,34 @@ export async function getPromotedWidgets(
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
return node?.properties?.proxyWidgets ?? []
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetSnapshot> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return {
|
||||
proxyWidgets: node?.properties?.proxyWidgets ?? [],
|
||||
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
|
||||
}
|
||||
}, nodeId)
|
||||
|
||||
return {
|
||||
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
|
||||
widgetNames: Array.isArray(raw.widgetNames)
|
||||
? raw.widgetNames.filter(
|
||||
(name): name is string => typeof name === 'string'
|
||||
)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -61,7 +75,7 @@ export async function getPromotedWidgetCount(
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
return entry[1].startsWith('$$')
|
||||
}
|
||||
|
||||
@@ -73,6 +87,14 @@ export async function getPseudoPreviewWidgets(
|
||||
return widgets.filter(isPseudoPreviewEntry)
|
||||
}
|
||||
|
||||
export async function getNonPreviewPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
|
||||
@@ -22,10 +22,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
// Enable change auto-queue mode
|
||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
|
||||
expect(await queueOpts.getMode()).toBe('disabled')
|
||||
await queueOpts.setMode('change')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => queueOpts.getMode()).toBe('change')
|
||||
expect(await queueOpts.getMode()).toBe('change')
|
||||
await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
|
||||
// Intercept the prompt queue endpoint
|
||||
@@ -124,8 +124,6 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
force: true
|
||||
}
|
||||
)
|
||||
await expect(comfyPage.actionbar.root.locator('.actionbar')).toHaveClass(
|
||||
/static/
|
||||
)
|
||||
expect(await comfyPage.actionbar.isDocked()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,23 +92,19 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(() => overlay.evaluate(isClippedByAnyAncestor))
|
||||
.toBe(false)
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
@@ -146,22 +142,18 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(() => popover.evaluate(isClippedByAnyAncestor))
|
||||
.toBe(false)
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -103,15 +103,14 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
|
||||
const keyBadges = bottomPanel.shortcuts.keyBadges
|
||||
await keyBadges.first().waitFor({ state: 'visible' })
|
||||
await expect.poll(() => keyBadges.count()).toBeGreaterThanOrEqual(1)
|
||||
const count = await keyBadges.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect
|
||||
.poll(() => keyBadges.allTextContents())
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/^(Ctrl|Cmd|Shift|Alt)$/)
|
||||
])
|
||||
)
|
||||
const badgeText = await keyBadges.allTextContents()
|
||||
const hasModifiers = badgeText.some((text) =>
|
||||
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
|
||||
)
|
||||
expect(hasModifiers).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should maintain panel state when switching between panels', async ({
|
||||
@@ -197,7 +196,8 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
).toBeVisible()
|
||||
|
||||
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
|
||||
await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
|
||||
const titleCount = await subcategoryTitles.count()
|
||||
expect(titleCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('should open shortcuts panel with Ctrl+Shift+K', async ({
|
||||
|
||||
@@ -21,7 +21,7 @@ async function saveCloseAndReopenAsApp(
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await expect(appMode.saveAs.successDialog).not.toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
@@ -25,15 +24,6 @@ async function reSaveAs(
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
}
|
||||
|
||||
async function dismissSuccessDialog(
|
||||
saveAs: BuilderSaveAsHelper,
|
||||
button: 'close' | 'dismiss' = 'close'
|
||||
) {
|
||||
const btn = button === 'close' ? saveAs.closeButton : saveAs.dismissButton
|
||||
await btn.click()
|
||||
await expect(saveAs.successDialog).not.toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
@@ -131,7 +121,8 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
||||
await dismissSuccessDialog(saveAs)
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Modify the workflow so the save button becomes enabled
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
@@ -152,7 +143,8 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
||||
await dismissSuccessDialog(saveAs)
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await footer.openSaveAsFromChevron()
|
||||
|
||||
@@ -169,11 +161,8 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await appMode.enterBuilder()
|
||||
|
||||
// State 1: Disabled "Save as" (no outputs selected)
|
||||
await expect(appMode.footer.saveAsButton).toBeVisible()
|
||||
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
if (!disabledBox)
|
||||
throw new Error('saveAsButton boundingBox returned null while visible')
|
||||
const disabledWidth = disabledBox.width
|
||||
expect(disabledBox).toBeTruthy()
|
||||
|
||||
// Select I/O to enable the button
|
||||
await appMode.steps.goToInputs()
|
||||
@@ -182,20 +171,19 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
|
||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await appMode.footer.saveAsButton.boundingBox())?.width
|
||||
)
|
||||
.toBe(disabledWidth)
|
||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
expect(enabledBox).toBeTruthy()
|
||||
expect(enabledBox!.width).toBe(disabledBox!.width)
|
||||
|
||||
// Save the workflow to transition to the Save + chevron state
|
||||
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
|
||||
await dismissSuccessDialog(appMode.saveAs)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// State 3: Save + chevron button group (saved workflow)
|
||||
await expect
|
||||
.poll(async () => (await appMode.footer.saveGroup.boundingBox())?.width)
|
||||
.toBe(disabledWidth)
|
||||
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
|
||||
expect(saveButtonGroupBox).toBeTruthy()
|
||||
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
@@ -218,13 +206,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toContain('.app.json')
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain('.app.json')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
|
||||
.toBe(true)
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
test('save as node graph produces correct extension and linearMode', async ({
|
||||
@@ -237,15 +223,12 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toMatch(/\.json$/)
|
||||
expect(path).not.toContain('.app.json')
|
||||
}).toPass({ timeout: 5000 })
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toMatch(/\.json$/)
|
||||
expect(path).not.toContain('.app.json')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
|
||||
.toBe(false)
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(false)
|
||||
})
|
||||
|
||||
test('save as app View App button enters app mode', async ({ comfyPage }) => {
|
||||
@@ -253,11 +236,11 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
||||
|
||||
await comfyPage.appMode.saveAs.viewAppButton.click()
|
||||
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowActiveAppMode())
|
||||
.toBe('app')
|
||||
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
|
||||
'app'
|
||||
)
|
||||
})
|
||||
|
||||
test('save as node graph Exit builder exits builder mode', async ({
|
||||
@@ -271,7 +254,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
)
|
||||
|
||||
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
||||
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
@@ -284,27 +267,27 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
const originalName = `${Date.now()} original`
|
||||
await builderSaveAs(appMode, originalName, 'App')
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toContain('.app.json')
|
||||
await dismissSuccessDialog(appMode.saveAs)
|
||||
const originalPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(originalPath).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Re-save as node graph — creates a copy
|
||||
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.not.toContain('.app.json')
|
||||
const newPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(newPath).not.toBe(originalPath)
|
||||
expect(newPath).not.toContain('.app.json')
|
||||
|
||||
// Dismiss success dialog, exit app mode, reopen the original
|
||||
await dismissSuccessDialog(appMode.saveAs, 'dismiss')
|
||||
await appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await appMode.toggleAppMode()
|
||||
await openWorkflowFromSidebar(comfyPage, originalName)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getLinearModeFromGraph())
|
||||
.toBe(true)
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
test('save as with same name and same mode overwrites in place', async ({
|
||||
@@ -315,25 +298,20 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
await dismissSuccessDialog(appMode.saveAs)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toContain('.app.json')
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
|
||||
await reSaveAs(appMode, name, 'App')
|
||||
|
||||
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.overwriteButton.click()
|
||||
await expect(appMode.saveAs.overwriteDialog).not.toBeVisible()
|
||||
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toBe(pathAfterFirst)
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).toBe(pathAfterFirst)
|
||||
})
|
||||
|
||||
test('save as with same name but different mode creates a new file', async ({
|
||||
@@ -344,38 +322,32 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toContain('.app.json')
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
await dismissSuccessDialog(appMode.saveAs)
|
||||
expect(pathAfterFirst).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await reSaveAs(appMode, name, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.not.toBe(pathAfterFirst)
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toMatch(/\.json$/)
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.not.toContain('.app.json')
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).not.toBe(pathAfterFirst)
|
||||
expect(pathAfterSecond).toMatch(/\.json$/)
|
||||
expect(pathAfterSecond).not.toContain('.app.json')
|
||||
})
|
||||
|
||||
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
|
||||
const name = `${Date.now()} reload-app`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'App')
|
||||
await dismissSuccessDialog(comfyPage.appMode.saveAs, 'dismiss')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowInitialMode())
|
||||
.toBe('app')
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('app')
|
||||
})
|
||||
|
||||
test('save as node graph workflow reloads in node graph mode', async ({
|
||||
@@ -384,13 +356,13 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
const name = `${Date.now()} reload-graph`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
|
||||
await dismissSuccessDialog(comfyPage.appMode.saveAs, 'dismiss')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowInitialMode())
|
||||
.toBe('graph')
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('graph')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const getLocators = (page: Page) => ({
|
||||
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
|
||||
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
|
||||
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
|
||||
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
|
||||
})
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
label: 'Select',
|
||||
activateCommand: 'Comfy.Canvas.Unlock',
|
||||
isReadOnly: false,
|
||||
iconPattern: /lucide--mouse-pointer-2/
|
||||
},
|
||||
{
|
||||
label: 'Hand',
|
||||
activateCommand: 'Comfy.Canvas.Lock',
|
||||
isReadOnly: true,
|
||||
iconPattern: /lucide--hand/
|
||||
}
|
||||
]
|
||||
|
||||
test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Trigger button', () => {
|
||||
test('visible in canvas toolbar with ARIA markup', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
for (const mode of MODES) {
|
||||
test(`shows ${mode.label}-mode icon on trigger button`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(mode.iconPattern)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Popover lifecycle', () => {
|
||||
test('opens when trigger is clicked', async ({ comfyPage }) => {
|
||||
const { trigger, menu } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('closes when trigger is clicked again', async ({ comfyPage }) => {
|
||||
const { trigger, menu } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('closes after a mode item is selected', async ({ comfyPage }) => {
|
||||
const { trigger, menu, handItem } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await handItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes when Escape is pressed', async ({ comfyPage }) => {
|
||||
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mode switching', () => {
|
||||
for (const mode of MODES) {
|
||||
test(`clicking "${mode.label}" sets canvas readOnly=${mode.isReadOnly}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
if (!mode.isReadOnly) {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
const item = mode.isReadOnly ? handItem : selectItem
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await item.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.isReadOnly())
|
||||
.toBe(mode.isReadOnly)
|
||||
})
|
||||
}
|
||||
|
||||
test('clicking the currently active item is a no-op', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts in Select mode'
|
||||
).toBe(false)
|
||||
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ARIA state', () => {
|
||||
test('aria-checked marks Select active on default load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(selectItem).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(handItem).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
for (const mode of MODES) {
|
||||
test(`tabindex=0 is on the active "${mode.label}" item`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const activeItem = mode.isReadOnly ? handItem : selectItem
|
||||
const inactiveItem = mode.isReadOnly ? selectItem : handItem
|
||||
|
||||
await expect(activeItem).toHaveAttribute('tabindex', '0')
|
||||
await expect(inactiveItem).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowDown moves focus from Select to Hand', async ({ comfyPage }) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('ArrowDown')
|
||||
await expect(handItem).toBeFocused()
|
||||
})
|
||||
|
||||
test('Escape closes popover and restores focus to trigger', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('ArrowDown')
|
||||
await handItem.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(trigger).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Focus management on open', () => {
|
||||
for (const mode of MODES) {
|
||||
test(`auto-focuses the checked "${mode.label}" item on open`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
const item = mode.isReadOnly ? handItem : selectItem
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(item).toBeFocused()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Keybinding integration', { tag: '@keyboard' }, () => {
|
||||
test("'H' locks canvas and updates trigger icon to Hand", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts unlocked'
|
||||
).toBe(false)
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(/lucide--hand/)
|
||||
})
|
||||
|
||||
test("'V' unlocks canvas and updates trigger icon to Select", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts locked'
|
||||
).toBe(true)
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(/lucide--mouse-pointer-2/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Shortcut hint display', () => {
|
||||
test('menu items show non-empty keyboard shortcut text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
const selectHint = selectItem.getByTestId('shortcut-hint')
|
||||
const handHint = handItem.getByTestId('shortcut-hint')
|
||||
|
||||
await expect(selectHint).not.toBeEmpty()
|
||||
await expect(handHint).not.toBeEmpty()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,94 +1,14 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type ChangeTrackerDebugState = {
|
||||
changeCount: number
|
||||
graphMatchesActiveState: boolean
|
||||
isLoadingGraph: boolean
|
||||
isModified: boolean | undefined
|
||||
redoQueueSize: number
|
||||
restoringState: boolean
|
||||
undoQueueSize: number
|
||||
}
|
||||
|
||||
async function getChangeTrackerDebugState(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
type ChangeTrackerClassLike = {
|
||||
graphEqual: (left: unknown, right: unknown) => boolean
|
||||
isLoadingGraph: boolean
|
||||
}
|
||||
|
||||
type ChangeTrackerLike = {
|
||||
_restoringState: boolean
|
||||
activeState: unknown
|
||||
changeCount: number
|
||||
constructor: ChangeTrackerClassLike
|
||||
redoQueue: unknown[]
|
||||
undoQueue: unknown[]
|
||||
}
|
||||
|
||||
type ActiveWorkflowLike = {
|
||||
changeTracker?: ChangeTrackerLike
|
||||
isModified?: boolean
|
||||
}
|
||||
|
||||
const workflowStore = window.app!.extensionManager as WorkspaceStore
|
||||
const workflow = workflowStore.workflow
|
||||
.activeWorkflow as ActiveWorkflowLike | null
|
||||
const tracker = workflow?.changeTracker
|
||||
if (!workflow || !tracker) {
|
||||
throw new Error('Active workflow change tracker is not available')
|
||||
}
|
||||
|
||||
const currentState = JSON.parse(
|
||||
JSON.stringify(window.app!.rootGraph.serialize())
|
||||
)
|
||||
return {
|
||||
changeCount: tracker.changeCount,
|
||||
graphMatchesActiveState: tracker.constructor.graphEqual(
|
||||
tracker.activeState,
|
||||
currentState
|
||||
),
|
||||
isLoadingGraph: tracker.constructor.isLoadingGraph,
|
||||
isModified: workflow.isModified,
|
||||
redoQueueSize: tracker.redoQueue.length,
|
||||
restoringState: tracker._restoringState,
|
||||
undoQueueSize: tracker.undoQueue.length
|
||||
} satisfies ChangeTrackerDebugState
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForChangeTrackerSettled(
|
||||
comfyPage: ComfyPage,
|
||||
expected: Pick<
|
||||
ChangeTrackerDebugState,
|
||||
'isModified' | 'redoQueueSize' | 'undoQueueSize'
|
||||
>
|
||||
) {
|
||||
// Visible node flags can flip before undo finishes loadGraphData() and
|
||||
// updates the tracker. Poll the tracker's own settled state so we do not
|
||||
// start the next transaction while checkState() is still gated.
|
||||
await expect
|
||||
.poll(() => getChangeTrackerDebugState(comfyPage))
|
||||
.toMatchObject({
|
||||
changeCount: 0,
|
||||
graphMatchesActiveState: true,
|
||||
isLoadingGraph: false,
|
||||
restoringState: false,
|
||||
...expected
|
||||
})
|
||||
}
|
||||
|
||||
async function beforeChange(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas!.emitBeforeChange()
|
||||
})
|
||||
}
|
||||
|
||||
async function afterChange(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas!.emitAfterChange()
|
||||
@@ -112,7 +32,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
|
||||
// Save, confirm no errors & workflow modified flag removed
|
||||
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||
await expect.poll(() => comfyPage.toast.getToastErrorCount()).toBe(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
@@ -139,19 +59,19 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeBypassed()
|
||||
await waitForChangeTrackerSettled(comfyPage, {
|
||||
isModified: true,
|
||||
redoQueueSize: 1,
|
||||
undoQueueSize: 1
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(1)
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
await waitForChangeTrackerSettled(comfyPage, {
|
||||
isModified: false,
|
||||
redoQueueSize: 2,
|
||||
undoQueueSize: 0
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,11 +98,6 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeBypassed()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
await waitForChangeTrackerSettled(comfyPage, {
|
||||
isModified: false,
|
||||
redoQueueSize: 2,
|
||||
undoQueueSize: 0
|
||||
})
|
||||
|
||||
// Prevent clicks registering a double-click
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
@@ -198,21 +113,11 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
|
||||
// End transaction
|
||||
await afterChange(comfyPage)
|
||||
await waitForChangeTrackerSettled(comfyPage, {
|
||||
isModified: true,
|
||||
redoQueueSize: 0,
|
||||
undoQueueSize: 1
|
||||
})
|
||||
|
||||
// Ensure undo reverts both changes
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeBypassed({ timeout: 5000 })
|
||||
await expect(node).not.toBeCollapsed({ timeout: 5000 })
|
||||
await waitForChangeTrackerSettled(comfyPage, {
|
||||
isModified: false,
|
||||
redoQueueSize: 1,
|
||||
undoQueueSize: 0
|
||||
})
|
||||
await expect(node).not.toBeBypassed()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
})
|
||||
|
||||
test('Can nest multiple change transactions without adding undo steps', async ({
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe(
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
@@ -42,21 +42,25 @@ test.describe(
|
||||
|
||||
// Create tab 1: blank workflow (0 nodes)
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to tab 0 (workflow-a).
|
||||
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
||||
await tab0.click()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// switch to blank tab and back to verify no corruption
|
||||
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
||||
await tab1.click()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// switch again and verify no corruption
|
||||
await tab0.click()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
|
||||
// Add a new CheckpointLoaderSimple — should use first cloud asset,
|
||||
// not the server's object_info default.
|
||||
// Production resolves via getAssetFilename (user_metadata.filename →
|
||||
// metadata.filename → asset.name). Test fixtures have no metadata
|
||||
// filename, so asset.name is the resolved value.
|
||||
const nodeId = await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
|
||||
window.app!.graph.add(node!)
|
||||
return node!.id
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
return String(widget?.value ?? '')
|
||||
}, nodeId)
|
||||
})
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
})
|
||||
@@ -35,8 +35,8 @@ test.describe(
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(async () => await node.boundingBox()).not.toBeNull()
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2
|
||||
|
||||
@@ -157,7 +157,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'custom-color-palette-obsidian-dark-all-colors.png'
|
||||
)
|
||||
@@ -178,7 +177,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).colorPalette.addCustomColorPalette(p)
|
||||
}, customColorPalettes.obsidian_dark)
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
|
||||
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
@@ -212,14 +211,12 @@ test.describe(
|
||||
|
||||
// Drag mouse to force canvas to redraw
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
|
||||
await comfyPage.page.mouse.move(8, 8)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||
})
|
||||
|
||||
@@ -228,8 +225,8 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.2-arc-theme.png'
|
||||
)
|
||||
@@ -241,38 +238,22 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
if (typeof graph.serialize !== 'function') return undefined
|
||||
const parsed = graph.serialize() as {
|
||||
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||
}
|
||||
return parsed.nodes
|
||||
})
|
||||
)
|
||||
.toBeDefined()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const nodes = await comfyPage.page.evaluate(() => {
|
||||
return (
|
||||
window.app!.graph!.serialize() as {
|
||||
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||
}
|
||||
).nodes
|
||||
})
|
||||
if (!Array.isArray(nodes)) return 'not an array'
|
||||
for (const node of nodes) {
|
||||
if (node.bgcolor && /hsla/.test(node.bgcolor))
|
||||
return `bgcolor contains hsla: ${node.bgcolor}`
|
||||
if (node.color && /hsla/.test(node.color))
|
||||
return `color contains hsla: ${node.color}`
|
||||
}
|
||||
return 'ok'
|
||||
})
|
||||
.toBe('ok')
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
if (typeof graph.serialize !== 'function') {
|
||||
throw new Error('app.graph.serialize is not available')
|
||||
}
|
||||
return graph.serialize() as {
|
||||
nodes: Array<{ bgcolor?: string; color?: string }>
|
||||
}
|
||||
})
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
const nodes = parsed.nodes
|
||||
for (const node of nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
})
|
||||
|
||||
test('should lighten node colors when switching to light theme', async ({
|
||||
|
||||
@@ -13,9 +13,7 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('TestCommand')
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
||||
.toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
|
||||
})
|
||||
|
||||
test('Should execute async command', async ({ comfyPage }) => {
|
||||
@@ -29,9 +27,7 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('TestCommand')
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
||||
.toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
|
||||
})
|
||||
|
||||
test('Should handle command errors', async ({ comfyPage }) => {
|
||||
@@ -40,7 +36,7 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('TestCommand')
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(1)
|
||||
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Should handle async command errors', async ({ comfyPage }) => {
|
||||
@@ -53,6 +49,6 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('TestCommand')
|
||||
await expect(comfyPage.toast.toastErrors).toHaveCount(1)
|
||||
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,19 +4,22 @@ import type { Locator } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function verifyCustomIconSvg(iconElement: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const svgVariable = await iconElement.evaluate((element) =>
|
||||
getComputedStyle(element).getPropertyValue('--svg')
|
||||
)
|
||||
if (!svgVariable) return null
|
||||
const dataUrlMatch = svgVariable.match(
|
||||
/url\("data:image\/svg\+xml,([^"]+)"\)/
|
||||
)
|
||||
if (!dataUrlMatch) return null
|
||||
return decodeURIComponent(dataUrlMatch[1])
|
||||
})
|
||||
.toContain("<svg xmlns='http://www.w3.org/2000/svg'")
|
||||
const svgVariable = await iconElement.evaluate((element) => {
|
||||
const styles = getComputedStyle(element)
|
||||
return styles.getPropertyValue('--svg')
|
||||
})
|
||||
|
||||
expect(svgVariable).toBeTruthy()
|
||||
const dataUrlMatch = svgVariable.match(
|
||||
/url\("data:image\/svg\+xml,([^"]+)"\)/
|
||||
)
|
||||
expect(dataUrlMatch).toBeTruthy()
|
||||
|
||||
const encodedSvg = dataUrlMatch![1]
|
||||
const decodedSvg = decodeURIComponent(encodedSvg)
|
||||
|
||||
// Check for SVG header to confirm it's a valid SVG
|
||||
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
|
||||
}
|
||||
|
||||
test.describe('Custom Icons', { tag: '@settings' }, () => {
|
||||
|
||||
@@ -41,9 +41,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.press(key)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(selectedButton).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -56,9 +58,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.canvas.press('Alt+Equal')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
.toBeGreaterThan(initialScale)
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeGreaterThan(initialScale)
|
||||
})
|
||||
|
||||
test("'Alt+-' zooms out", async ({ comfyPage }) => {
|
||||
@@ -67,17 +68,15 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.canvas.press('Alt+Minus')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
.toBeLessThan(initialScale)
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test("'.' fits view to nodes", async ({ comfyPage }) => {
|
||||
// Set scale very small so fit-view will zoom back to fit nodes
|
||||
await comfyPage.canvasOps.setScale(0.1)
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
.toBeCloseTo(0.1, 1)
|
||||
const scaleBefore = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleBefore).toBeCloseTo(0.1, 1)
|
||||
|
||||
// Click canvas to ensure focus is within graph-canvas-container
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
|
||||
@@ -86,30 +85,29 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.canvas.press('Period')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
.toBeGreaterThan(0.1)
|
||||
const scaleAfter = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfter).toBeGreaterThan(scaleBefore)
|
||||
})
|
||||
|
||||
test("'h' locks canvas", async ({ comfyPage }) => {
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test("'v' unlocks canvas", async ({ comfyPage }) => {
|
||||
// Lock first
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,15 +122,15 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await node.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => node.isCollapsed()).toBe(true)
|
||||
expect(await node.isCollapsed()).toBe(true)
|
||||
|
||||
await comfyPage.canvas.press('Alt+KeyC')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => node.isCollapsed()).toBe(false)
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
|
||||
@@ -149,16 +147,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
||||
}, node.id)
|
||||
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
expect(await getMode()).toBe(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
// NEVER (2) = muted
|
||||
await expect.poll(() => getMode()).toBe(2)
|
||||
expect(await getMode()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.press('Control+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
expect(await getMode()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,10 +170,12 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
|
||||
// Toggle off with Alt+m
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
|
||||
|
||||
// Toggle on again
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -189,9 +189,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -201,9 +203,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+Backquote')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -274,9 +278,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.page.keyboard.press('Control+o')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
.toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -284,14 +286,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect
|
||||
.poll(
|
||||
() => comfyPage.nodeOps.getGraphNodesCount(),
|
||||
'Default workflow should have multiple nodes'
|
||||
)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBeGreaterThan(1)
|
||||
|
||||
// Select all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
|
||||
@@ -17,9 +17,10 @@ test.describe('Settings', () => {
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const contentArea = settingsDialog.locator('main')
|
||||
await expect(contentArea).toBeVisible()
|
||||
await expect
|
||||
.poll(() => contentArea.evaluate((el) => el.clientHeight))
|
||||
.toBeGreaterThan(30)
|
||||
const isUsableHeight = await contentArea.evaluate(
|
||||
(el) => el.clientHeight > 30
|
||||
)
|
||||
expect(isUsableHeight).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Can open settings with hotkey', async ({ comfyPage }) => {
|
||||
@@ -38,27 +39,27 @@ test.describe('Settings', () => {
|
||||
const maxSpeed = 2.5
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
|
||||
await test.step('Setting should persist', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
|
||||
.toBe(maxSpeed)
|
||||
expect(await comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
|
||||
maxSpeed
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||
// Open the settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
||||
|
||||
// Open the keybinding tab
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
await settingsDialog
|
||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||
.click()
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Keybindings...')
|
||||
).toBeVisible()
|
||||
await comfyPage.page.waitForSelector(
|
||||
'[placeholder="Search Keybindings..."]'
|
||||
)
|
||||
|
||||
// Focus the 'New Blank Workflow' row
|
||||
const newBlankWorkflowRow = comfyPage.page.locator('tr', {
|
||||
@@ -155,6 +156,6 @@ test.describe('Signin dialog', () => {
|
||||
await input.press('Control+v')
|
||||
await expect(input).toHaveValue('test_password')
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
||||
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
import type { components as RegistryComponents } from '@comfyorg/registry-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
|
||||
type InstalledPacksResponse =
|
||||
ManagerComponents['schemas']['InstalledPacksResponse']
|
||||
type RegistryNodePack = RegistryComponents['schemas']['Node']
|
||||
|
||||
interface AlgoliaSearchResult {
|
||||
hits: Partial<AlgoliaNodePack>[]
|
||||
nbHits: number
|
||||
page: number
|
||||
nbPages: number
|
||||
hitsPerPage: number
|
||||
}
|
||||
|
||||
interface AlgoliaSearchResponse {
|
||||
results: AlgoliaSearchResult[]
|
||||
}
|
||||
|
||||
const MOCK_PACK_A: RegistryNodePack = {
|
||||
id: 'test-pack-a',
|
||||
name: 'Test Pack A',
|
||||
description: 'A test custom node pack',
|
||||
downloads: 5000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher: { id: 'test-publisher', name: 'Test Publisher' },
|
||||
latest_version: { version: '1.0.0', status: 'NodeVersionStatusActive' },
|
||||
repository: 'https://github.com/test/pack-a',
|
||||
tags: ['image', 'processing']
|
||||
}
|
||||
|
||||
const MOCK_PACK_B: RegistryNodePack = {
|
||||
id: 'test-pack-b',
|
||||
name: 'Test Pack B',
|
||||
description: 'Another test custom node pack for testing search',
|
||||
downloads: 3000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher: { id: 'another-publisher', name: 'Another Publisher' },
|
||||
latest_version: { version: '2.1.0', status: 'NodeVersionStatusActive' },
|
||||
repository: 'https://github.com/test/pack-b',
|
||||
tags: ['video', 'generation']
|
||||
}
|
||||
|
||||
const MOCK_PACK_C: RegistryNodePack = {
|
||||
id: 'test-pack-c',
|
||||
name: 'Test Pack C',
|
||||
description: 'Third test pack',
|
||||
downloads: 100,
|
||||
status: 'NodeStatusActive',
|
||||
publisher: { id: 'test-publisher', name: 'Test Publisher' },
|
||||
latest_version: { version: '0.5.0', status: 'NodeVersionStatusActive' },
|
||||
repository: 'https://github.com/test/pack-c'
|
||||
}
|
||||
|
||||
const MOCK_INSTALLED_PACKS: InstalledPacksResponse = {
|
||||
'test-pack-a': {
|
||||
ver: '1.0.0',
|
||||
cnr_id: 'test-pack-a',
|
||||
enabled: true
|
||||
},
|
||||
'test-pack-c': {
|
||||
ver: '0.5.0',
|
||||
cnr_id: 'test-pack-c',
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_HIT_A: Partial<AlgoliaNodePack> = {
|
||||
objectID: 'test-pack-a',
|
||||
id: 'test-pack-a',
|
||||
name: 'Test Pack A',
|
||||
description: 'A test custom node pack',
|
||||
total_install: 5000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher_id: 'test-publisher',
|
||||
latest_version: '1.0.0',
|
||||
latest_version_status: 'NodeVersionStatusActive',
|
||||
repository_url: 'https://github.com/test/pack-a',
|
||||
comfy_nodes: ['TestNodeA1', 'TestNodeA2'],
|
||||
create_time: '2024-01-01T00:00:00Z',
|
||||
update_time: '2024-06-01T00:00:00Z',
|
||||
license: 'MIT',
|
||||
tags: ['image', 'processing']
|
||||
}
|
||||
|
||||
const MOCK_HIT_B: Partial<AlgoliaNodePack> = {
|
||||
objectID: 'test-pack-b',
|
||||
id: 'test-pack-b',
|
||||
name: 'Test Pack B',
|
||||
description: 'Another test custom node pack for testing search',
|
||||
total_install: 3000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher_id: 'another-publisher',
|
||||
latest_version: '2.1.0',
|
||||
latest_version_status: 'NodeVersionStatusActive',
|
||||
repository_url: 'https://github.com/test/pack-b',
|
||||
comfy_nodes: ['TestNodeB1'],
|
||||
create_time: '2024-02-01T00:00:00Z',
|
||||
update_time: '2024-07-01T00:00:00Z',
|
||||
license: 'Apache-2.0',
|
||||
tags: ['video', 'generation']
|
||||
}
|
||||
|
||||
const MOCK_HIT_C: Partial<AlgoliaNodePack> = {
|
||||
objectID: 'test-pack-c',
|
||||
id: 'test-pack-c',
|
||||
name: 'Test Pack C',
|
||||
description: 'Third test pack',
|
||||
total_install: 100,
|
||||
status: 'NodeStatusActive',
|
||||
publisher_id: 'test-publisher',
|
||||
latest_version: '0.5.0',
|
||||
latest_version_status: 'NodeVersionStatusActive',
|
||||
repository_url: 'https://github.com/test/pack-c',
|
||||
comfy_nodes: ['TestNodeC1'],
|
||||
create_time: '2024-03-01T00:00:00Z',
|
||||
update_time: '2024-05-01T00:00:00Z',
|
||||
license: 'MIT'
|
||||
}
|
||||
|
||||
const MOCK_ALGOLIA_RESPONSE: AlgoliaSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
hits: [MOCK_HIT_A, MOCK_HIT_B, MOCK_HIT_C],
|
||||
nbHits: 3,
|
||||
page: 0,
|
||||
nbPages: 1,
|
||||
hitsPerPage: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const MOCK_ALGOLIA_PACK_B_ONLY: AlgoliaSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
hits: [MOCK_HIT_B],
|
||||
nbHits: 1,
|
||||
page: 0,
|
||||
nbPages: 1,
|
||||
hitsPerPage: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const MOCK_ALGOLIA_EMPTY: AlgoliaSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
hits: [],
|
||||
nbHits: 0,
|
||||
page: 0,
|
||||
nbPages: 0,
|
||||
hitsPerPage: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
const statsWithManager = {
|
||||
...mockSystemStats,
|
||||
system: {
|
||||
...mockSystemStats.system,
|
||||
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({ json: statsWithManager })
|
||||
})
|
||||
|
||||
await comfyPage.featureFlags.mockServerFeatures({
|
||||
'extension.manager.supports_v4': true
|
||||
})
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/customnode/installed**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: MOCK_INSTALLED_PACKS })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/manager/queue/status**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
history: {},
|
||||
running_queue: [],
|
||||
pending_queue: [],
|
||||
installed_packs: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/manager/queue/history**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: {} })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
|
||||
})
|
||||
|
||||
// Mock Comfy Registry API (fallback when Algolia credentials are unavailable)
|
||||
const registryListResponse = {
|
||||
total: 3,
|
||||
nodes: [MOCK_PACK_A, MOCK_PACK_B, MOCK_PACK_C],
|
||||
page: 1,
|
||||
limit: 64,
|
||||
totalPages: 1
|
||||
}
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/search**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: registryListResponse })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: registryListResponse })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/customnode/getmappings**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: {} })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/customnode/import_fail_info**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: {} })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function openManagerDialog(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.OpenManagerDialog')
|
||||
}
|
||||
|
||||
test('Opens the manager dialog via command', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Displays pack cards from search results', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(dialog.getByText('Test Pack A')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack C')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters displayed packs', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
|
||||
})
|
||||
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
|
||||
})
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/search**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
total: 1,
|
||||
nodes: [MOCK_PACK_B],
|
||||
page: 1,
|
||||
limit: 64,
|
||||
totalPages: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||
await searchInput.fill('Test Pack B')
|
||||
|
||||
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack A')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/test-pack-a',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: MOCK_PACK_A })
|
||||
}
|
||||
)
|
||||
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByText('Test Pack A').first().click()
|
||||
|
||||
await expect(dialog.getByText('Test Publisher').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Left side panel navigation tabs exist', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const nav = dialog.locator('nav')
|
||||
await expect(nav.getByText('All Extensions')).toBeVisible()
|
||||
await expect(nav.getByText('Not Installed')).toBeVisible()
|
||||
await expect(nav.getByText('All Installed')).toBeVisible()
|
||||
await expect(nav.getByText('Updates Available')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Switching tabs changes the content view', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const nav = dialog.locator('nav')
|
||||
await nav.getByText('All Installed').click()
|
||||
|
||||
await expect(dialog.getByText('Test Pack A')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Closes via Escape key', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty search shows no results message', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
|
||||
})
|
||||
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
|
||||
})
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/search**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
total: 0,
|
||||
nodes: [],
|
||||
page: 1,
|
||||
limit: 64,
|
||||
totalPages: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||
await searchInput.fill('nonexistent-pack-xyz-999')
|
||||
|
||||
await expect(
|
||||
dialog.getByText(/no results found|try a different search/i).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search mode can be switched between packs and nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const modeSelector = dialog.getByText('Node Pack').first()
|
||||
await expect(modeSelector).toBeVisible()
|
||||
|
||||
await modeSelector.click()
|
||||
const nodesOption = comfyPage.page.getByRole('option', { name: 'Nodes' })
|
||||
await expect(nodesOption).toBeVisible()
|
||||
await nodesOption.click()
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
|
||||
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
|
||||
|
||||
test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
test('About panel renders mocked version from server', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const stats = {
|
||||
...mockSystemStats,
|
||||
system: {
|
||||
...mockSystemStats.system,
|
||||
comfyui_version: MOCK_COMFYUI_VERSION
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({ json: stats })
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
await dialog.goToAboutPanel()
|
||||
|
||||
const aboutPanel = comfyPage.page.getByTestId('about-panel')
|
||||
await expect(aboutPanel).toBeVisible()
|
||||
await expect(aboutPanel).toContainText(MOCK_COMFYUI_VERSION)
|
||||
await expect(aboutPanel).toContainText('ComfyUI_frontend')
|
||||
})
|
||||
|
||||
test('Toggling a boolean setting through UI persists the value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingId = 'Comfy.Validation.Workflows'
|
||||
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
|
||||
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
try {
|
||||
await dialog.searchBox.fill('Validate workflows')
|
||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||
await expect(settingRow).toBeVisible()
|
||||
|
||||
await settingRow.locator('.p-toggleswitch').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Can be closed via close button', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await dialog.close()
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
await expect(dialog.root).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters settings list', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const settingItems = dialog.root.locator('[data-setting-id]')
|
||||
const countBeforeSearch = await settingItems.count()
|
||||
|
||||
await dialog.searchBox.fill('Validate workflows')
|
||||
await expect
|
||||
.poll(() => settingItems.count())
|
||||
.toBeLessThan(countBeforeSearch)
|
||||
})
|
||||
|
||||
test('Search can be cleared to restore all settings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const settingItems = dialog.root.locator('[data-setting-id]')
|
||||
const countBeforeSearch = await settingItems.count()
|
||||
|
||||
await dialog.searchBox.fill('Validate workflows')
|
||||
await expect
|
||||
.poll(() => settingItems.count())
|
||||
.toBeLessThan(countBeforeSearch)
|
||||
|
||||
await dialog.searchBox.clear()
|
||||
await expect.poll(() => settingItems.count()).toBe(countBeforeSearch)
|
||||
})
|
||||
|
||||
test('Category navigation changes content area', async ({ comfyPage }) => {
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const firstCategory = dialog.categories.first()
|
||||
const firstCategoryName = await firstCategory.textContent()
|
||||
await firstCategory.click()
|
||||
const firstContent = await dialog.contentArea.textContent()
|
||||
|
||||
// Find a different category to click
|
||||
const categoryCount = await dialog.categories.count()
|
||||
let switched = false
|
||||
for (let i = 1; i < categoryCount; i++) {
|
||||
const cat = dialog.categories.nth(i)
|
||||
const catName = await cat.textContent()
|
||||
if (catName !== firstCategoryName) {
|
||||
await cat.click()
|
||||
await expect
|
||||
.poll(() => dialog.contentArea.textContent())
|
||||
.not.toBe(firstContent)
|
||||
switched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(switched).toBe(true)
|
||||
})
|
||||
|
||||
test('Dropdown setting can be changed and persists', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingId = 'Comfy.UseNewMenu'
|
||||
const initialValue = await comfyPage.settings.getSetting<string>(settingId)
|
||||
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
try {
|
||||
await dialog.searchBox.fill('Use new menu')
|
||||
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
|
||||
await expect(settingRow).toBeVisible()
|
||||
|
||||
// Open the dropdown via its combobox role and verify it expanded.
|
||||
// Retry because the PrimeVue Select may re-render during search
|
||||
// filtering, causing the first click to land on a stale element.
|
||||
const select = settingRow.getByRole('combobox')
|
||||
await expect(async () => {
|
||||
const expanded = await select.getAttribute('aria-expanded')
|
||||
if (expanded !== 'true') await select.click()
|
||||
await expect(select).toHaveAttribute('aria-expanded', 'true')
|
||||
}).toPass({ timeout: 3000 })
|
||||
|
||||
// Pick the option that is not the current value
|
||||
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: targetValue, exact: true })
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<string>(settingId))
|
||||
.toBe(targetValue)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,352 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
interface PublishRecord {
|
||||
workflow_id: string
|
||||
share_id: string | null
|
||||
listed: boolean
|
||||
publish_time: string | null
|
||||
}
|
||||
|
||||
const PUBLISHED_RECORD: PublishRecord = {
|
||||
workflow_id: 'wf-1',
|
||||
share_id: 'share-abc',
|
||||
listed: false,
|
||||
publish_time: new Date(Date.now() + 60_000).toISOString()
|
||||
}
|
||||
|
||||
const PRIVATE_ASSET: AssetInfo = {
|
||||
id: 'asset-1',
|
||||
name: 'photo.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
/**
|
||||
* Enable the workflow_sharing_enabled server feature flag at runtime.
|
||||
* FeatureFlagHelper.mockServerFeatures() intercepts `/api/features` but the
|
||||
* flags are already loaded by the time tests run, so direct mutation of the
|
||||
* reactive ref is the only reliable approach for server-side flags.
|
||||
*/
|
||||
async function enableWorkflowSharing(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
workflow_sharing_enabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mockPublishStatus(
|
||||
page: Page,
|
||||
record: PublishRecord | null
|
||||
): Promise<void> {
|
||||
await page.route('**/api/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
if (!record || !record.share_id) {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(record)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mockPublishWorkflow(
|
||||
page: Page,
|
||||
result: PublishRecord
|
||||
): Promise<void> {
|
||||
await page.route('**/api/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(result)
|
||||
})
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mockShareableAssets(
|
||||
page: Page,
|
||||
assets: AssetInfo[] = []
|
||||
): Promise<void> {
|
||||
await page.route('**/api/assets/from-workflow', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ assets })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the active workflow via the topbar Save menu action.
|
||||
* Mocks the userdata POST endpoint to avoid real server calls in tests.
|
||||
*/
|
||||
async function saveAndWait(
|
||||
comfyPage: {
|
||||
page: Page
|
||||
menu: { topbar: { saveWorkflow: (name: string) => Promise<void> } }
|
||||
},
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
const filename =
|
||||
workflowName + (workflowName.endsWith('.json') ? '' : '.json')
|
||||
await comfyPage.page.route(
|
||||
/\/api\/userdata\/workflows(%2F|\/).*$/,
|
||||
async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
path: `workflows/${filename}`,
|
||||
size: 1024,
|
||||
modified: Date.now()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
}
|
||||
|
||||
async function openShareDialog(page: Page): Promise<void> {
|
||||
await enableWorkflowSharing(page)
|
||||
await dismissOverlays(page)
|
||||
const shareButton = page.getByRole('button', { name: 'Share workflow' })
|
||||
await shareButton.click()
|
||||
}
|
||||
|
||||
function getShareDialog(page: Page) {
|
||||
return page.getByRole('dialog')
|
||||
}
|
||||
|
||||
test.describe('Share Workflow Dialog', { tag: '@cloud' }, () => {
|
||||
test('should show unsaved state for a new workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /save workflow/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show ready state with create link button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-ready'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /create a link/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show shared state with copy URL after publishing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-shared'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, PUBLISHED_RECORD)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: /share.*url/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show stale state with update link button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-stale'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
const staleRecord: PublishRecord = {
|
||||
...PUBLISHED_RECORD,
|
||||
publish_time: '2020-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
await mockPublishStatus(page, staleRecord)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /update\s+link/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close dialog when close button is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('should create link and transition to shared state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-create'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await mockPublishWorkflow(page, PUBLISHED_RECORD)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
const createButton = dialog.getByRole('button', { name: /create a link/i })
|
||||
await expect(createButton).toBeVisible()
|
||||
await createButton.click()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: /share.*url/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show tab buttons when comfyHubUploadEnabled is true', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
comfyhub_upload_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole('tab', { name: /share/i })).toBeVisible()
|
||||
await expect(dialog.getByRole('tab', { name: /publish/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between share link and publish tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
comfyhub_upload_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('tab', { name: /publish/i }).click()
|
||||
|
||||
const publishPanel = dialog.getByTestId(TestIds.dialogs.publishTabPanel)
|
||||
await expect(publishPanel).toBeVisible()
|
||||
|
||||
await dialog.getByRole('tab', { name: /share/i }).click()
|
||||
await expect(publishPanel).toBeHidden()
|
||||
})
|
||||
|
||||
test('should require acknowledgment before publishing private assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-ack'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page, [PRIVATE_ASSET])
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
const createButton = dialog.getByRole('button', { name: /create a link/i })
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await dialog.getByRole('checkbox').check()
|
||||
await expect(createButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -35,14 +35,14 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||
}
|
||||
)
|
||||
|
||||
// No DOM widget should be created by creation of interim LGraphNode objects.
|
||||
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.domWidgets.count()
|
||||
const initialCount = await comfyPage.getDOMWidgetCount()
|
||||
|
||||
// TextEncodeNode1
|
||||
await comfyPage.page.mouse.move(618, 191)
|
||||
@@ -52,6 +52,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
await expect(comfyPage.domWidgets).toHaveCount(initialCount + 1)
|
||||
const finalCount = await comfyPage.getDOMWidgetCount()
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,9 +100,8 @@ test.describe('Error dialog', () => {
|
||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
|
||||
|
||||
const reportText = await errorDialog.locator('pre').textContent()
|
||||
await expect
|
||||
.poll(async () => await getClipboardText(comfyPage.page))
|
||||
.toBe(reportText)
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toBe(reportText)
|
||||
})
|
||||
|
||||
test('Should open GitHub issues search when "Find Issues" is clicked', async ({
|
||||
|
||||
@@ -47,16 +47,11 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(
|
||||
`${url}/api/devtools/cleanup_fake_model`
|
||||
)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
)
|
||||
.toBeTruthy()
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
@@ -156,7 +151,6 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -49,34 +49,18 @@ test.describe(
|
||||
const input = await comfyPage.nodeOps.getNodeRefById(3)
|
||||
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
|
||||
await expect
|
||||
.poll(async () => (await input.getWidget(0)).getValue())
|
||||
.toBe('foo')
|
||||
await expect
|
||||
.poll(async () => (await output1.getWidget(0)).getValue())
|
||||
.toBe('')
|
||||
await expect
|
||||
.poll(async () => (await output2.getWidget(0)).getValue())
|
||||
.toBe('')
|
||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output1.getWidget(0)).getValue()).toBe('')
|
||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
||||
|
||||
await output1.click('title')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
|
||||
await expect
|
||||
.poll(async () => (await input.getWidget(0)).getValue(), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.toBe('foo')
|
||||
await expect
|
||||
.poll(async () => (await output1.getWidget(0)).getValue(), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.toBe('foo')
|
||||
await expect
|
||||
.poll(async () => (await output2.getWidget(0)).getValue(), {
|
||||
timeout: 2_000
|
||||
})
|
||||
.toBe('')
|
||||
await expect(async () => {
|
||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -40,9 +40,7 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
||||
.toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
|
||||
})
|
||||
|
||||
test('Should not allow register command defined in other extension', async ({
|
||||
@@ -62,7 +60,7 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
|
||||
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
|
||||
await expect(menuItem).toHaveCount(0)
|
||||
expect(await menuItem.count()).toBe(0)
|
||||
})
|
||||
|
||||
test('Should allow registering keybindings', async ({ comfyPage }) => {
|
||||
@@ -88,9 +86,7 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('k')
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
.toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
@@ -113,20 +109,16 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
})
|
||||
// onChange is called when the setting is first added
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||
.toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('TestSetting'))
|
||||
.toBe('Hello, world!')
|
||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
|
||||
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
|
||||
'Hello, world!'
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('TestSetting'))
|
||||
.toBe('Hello, universe!')
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||
.toBe(2)
|
||||
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
|
||||
'Hello, universe!'
|
||||
)
|
||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
|
||||
})
|
||||
|
||||
test('Should allow setting boolean settings', async ({ comfyPage }) => {
|
||||
@@ -148,21 +140,17 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
|
||||
.toBe(false)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||
.toBe(1)
|
||||
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
|
||||
false
|
||||
)
|
||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
|
||||
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
||||
.toBe(2)
|
||||
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
|
||||
true
|
||||
)
|
||||
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
|
||||
})
|
||||
|
||||
test.describe('Passing through attrs to setting components', () => {
|
||||
@@ -240,15 +228,12 @@ test.describe('Topbar commands', () => {
|
||||
.getByText('TestSetting Test')
|
||||
.locator(selector)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
component.evaluate((el) =>
|
||||
el.tagName === 'INPUT'
|
||||
? (el as HTMLInputElement).disabled
|
||||
: el.classList.contains('p-disabled')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
const isDisabled = await component.evaluate((el) =>
|
||||
el.tagName === 'INPUT'
|
||||
? (el as HTMLInputElement).disabled
|
||||
: el.classList.contains('p-disabled')
|
||||
)
|
||||
expect(isDisabled).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -273,7 +258,7 @@ test.describe('Topbar commands', () => {
|
||||
await comfyPage.settingDialog.goToAboutPanel()
|
||||
const badge = comfyPage.page.locator('.about-badge').last()
|
||||
expect(badge).toBeDefined()
|
||||
await expect(badge).toContainText('Test Badge')
|
||||
expect(await badge.textContent()).toContain('Test Badge')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -291,13 +276,11 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
|
||||
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>)['value']
|
||||
)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>)['value']
|
||||
)
|
||||
.toBe('Hello, world!')
|
||||
).toBe('Hello, world!')
|
||||
})
|
||||
|
||||
test('Should allow showing a confirmation dialog', async ({
|
||||
@@ -315,13 +298,11 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
|
||||
await comfyPage.confirmDialog.click('confirm')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>)['value']
|
||||
)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>)['value']
|
||||
)
|
||||
.toBe(true)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
|
||||
@@ -338,13 +319,11 @@ test.describe('Topbar commands', () => {
|
||||
})
|
||||
|
||||
await comfyPage.confirmDialog.click('reject')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>)['value']
|
||||
)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>)['value']
|
||||
)
|
||||
.toBeNull()
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,16 +363,14 @@ test.describe('Topbar commands', () => {
|
||||
)
|
||||
await toolboxButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() =>
|
||||
(window as unknown as Record<string, unknown>)[
|
||||
'selectionCommandExecuted'
|
||||
]
|
||||
)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(
|
||||
() =>
|
||||
(window as unknown as Record<string, unknown>)[
|
||||
'selectionCommandExecuted'
|
||||
]
|
||||
)
|
||||
.toBe(true)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,30 +59,31 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Get the captured messages
|
||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||
|
||||
// Verify client sent feature flags
|
||||
await expect(async () => {
|
||||
const flags = await newPage.evaluate(
|
||||
() => window.__capturedMessages?.clientFeatureFlags
|
||||
)
|
||||
expect(flags).not.toBeNull()
|
||||
expect(flags?.type).toBe('feature_flags')
|
||||
expect(flags?.data).not.toBeNull()
|
||||
expect(flags?.data).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof flags?.data?.supports_preview_metadata).toBe('boolean')
|
||||
}).toPass()
|
||||
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(
|
||||
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||
).toBe('boolean')
|
||||
|
||||
// Verify server sent feature flags back
|
||||
await expect(async () => {
|
||||
const flags = await newPage.evaluate(
|
||||
() => window.__capturedMessages?.serverFeatureFlags
|
||||
)
|
||||
expect(flags).not.toBeNull()
|
||||
expect(flags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
|
||||
expect(flags).toHaveProperty('max_upload_size')
|
||||
expect(typeof flags?.max_upload_size).toBe('number')
|
||||
expect(Object.keys(flags ?? {}).length).toBeGreaterThan(0)
|
||||
}).toPass()
|
||||
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
@@ -90,44 +91,37 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
test('Server feature flags are received and accessible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverFeatureFlags.value
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
await expect(async () => {
|
||||
const flags = await comfyPage.page.evaluate(
|
||||
() => window.app!.api.serverFeatureFlags.value
|
||||
)
|
||||
expect(flags).not.toBeNull()
|
||||
expect(Object.keys(flags).length).toBeGreaterThan(0)
|
||||
// The backend should send feature flags
|
||||
expect(flags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof flags.supports_preview_metadata).toBe('boolean')
|
||||
expect(flags).toHaveProperty('max_upload_size')
|
||||
expect(typeof flags.max_upload_size).toBe('number')
|
||||
}).toPass()
|
||||
expect(serverFlags).toBeTruthy()
|
||||
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
|
||||
|
||||
// The backend should send feature flags
|
||||
expect(serverFlags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
|
||||
expect(serverFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof serverFlags.max_upload_size).toBe('number')
|
||||
})
|
||||
|
||||
test('serverSupportsFeature method works with real backend flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test serverSupportsFeature with real backend flags
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() =>
|
||||
typeof window.app!.api.serverSupportsFeature(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe('boolean')
|
||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverSupportsFeature('supports_preview_metadata')
|
||||
})
|
||||
// The method should return a boolean based on the backend's value
|
||||
expect(typeof supportsPreviewMetadata).toBe('boolean')
|
||||
|
||||
// Test non-existent feature - should always return false
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() =>
|
||||
window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
})
|
||||
expect(supportsNonExistent).toBe(false)
|
||||
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
@@ -166,51 +160,41 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test getServerFeature method
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() =>
|
||||
typeof window.app!.api.getServerFeature('supports_preview_metadata')
|
||||
)
|
||||
)
|
||||
.toBe('boolean')
|
||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeature('supports_preview_metadata')
|
||||
})
|
||||
expect(typeof previewMetadataValue).toBe('boolean')
|
||||
|
||||
// Test getting max_upload_size
|
||||
await expect(async () => {
|
||||
const maxUpload = await comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getServerFeature('max_upload_size')
|
||||
)
|
||||
expect(typeof maxUpload).toBe('number')
|
||||
expect(maxUpload as number).toBeGreaterThan(0)
|
||||
}).toPass()
|
||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeature('max_upload_size')
|
||||
})
|
||||
expect(typeof maxUploadSize).toBe('number')
|
||||
expect(maxUploadSize).toBeGreaterThan(0)
|
||||
|
||||
// Test getServerFeature with default value for non-existent feature
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
)
|
||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
.toBe('default')
|
||||
})
|
||||
expect(defaultValue).toBe('default')
|
||||
})
|
||||
|
||||
test('getServerFeatures returns all backend feature flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test getServerFeatures returns all flags
|
||||
await expect(async () => {
|
||||
const features = await comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getServerFeatures()
|
||||
)
|
||||
expect(features).not.toBeNull()
|
||||
expect(features).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof features.supports_preview_metadata).toBe('boolean')
|
||||
expect(features).toHaveProperty('max_upload_size')
|
||||
expect(Object.keys(features).length).toBeGreaterThan(0)
|
||||
}).toPass()
|
||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeatures()
|
||||
})
|
||||
|
||||
expect(allFeatures).toBeTruthy()
|
||||
expect(allFeatures).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
|
||||
expect(allFeatures).toHaveProperty('max_upload_size')
|
||||
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
@@ -340,22 +324,26 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Verify feature flags are available
|
||||
await expect(async () => {
|
||||
const flags = await newPage.evaluate(
|
||||
() => window.app!.api.serverFeatureFlags.value
|
||||
)
|
||||
expect(flags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
|
||||
expect(flags).toHaveProperty('max_upload_size')
|
||||
}).toPass()
|
||||
// Get readiness state
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...window.__appReadiness,
|
||||
currentFlags: window.app!.api.serverFeatureFlags.value
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feature flags were received and API was initialized
|
||||
await expect(async () => {
|
||||
const readiness = await newPage.evaluate(() => window.__appReadiness)
|
||||
expect(readiness?.featureFlagsReceived).toBe(true)
|
||||
expect(readiness?.apiInitialized).toBe(true)
|
||||
}).toPass()
|
||||
// Verify feature flags are available
|
||||
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
|
||||
|
||||
// Verify feature flags were received (we detected them via polling)
|
||||
expect(readiness.featureFlagsReceived).toBe(true)
|
||||
|
||||
// Verify API was initialized (feature flags require API)
|
||||
expect(readiness.apiInitialized).toBe(true)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
|
||||
@@ -29,9 +29,11 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.menu.sideToolbar).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -11,19 +11,17 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
||||
test('Fix link input slots', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test('Validate workflow links', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
|
||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
@@ -38,77 +36,72 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
function evaluateGraph() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Poll graph state once, then assert all properties
|
||||
await expect(async () => {
|
||||
const r = await evaluateGraph()
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(r.cfg85Linked).toBe(true)
|
||||
expect(r.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(r.cfg85LinkValid).toBe(true)
|
||||
expect(r.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(r.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(r.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(r.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([r.cfg85LinkId, r.cfg86LinkId])
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
}).toPass()
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(result.cfg85Linked).toBe(true)
|
||||
expect(result.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(result.cfg85LinkValid).toBe(true)
|
||||
expect(result.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(result.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(result.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,18 +31,18 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||
return window.LiteGraph!.HIDDEN_LINK
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.LinkRenderMode'))
|
||||
.toBe(hiddenLinkRenderMode)
|
||||
expect(await comfyPage.settings.getSetting('Comfy.LinkRenderMode')).toBe(
|
||||
hiddenLinkRenderMode
|
||||
)
|
||||
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-visible-links.png'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.LinkRenderMode'))
|
||||
.not.toBe(hiddenLinkRenderMode)
|
||||
expect(
|
||||
await comfyPage.settings.getSetting('Comfy.LinkRenderMode')
|
||||
).not.toBe(hiddenLinkRenderMode)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -92,6 +92,7 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
// Click backdrop to close
|
||||
const backdrop = comfyPage.page.locator('.fixed.inset-0').first()
|
||||
await backdrop.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Modal should be hidden
|
||||
await expect(zoomModal).not.toBeVisible()
|
||||
|
||||
@@ -28,20 +28,17 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const getGroupPositions = () =>
|
||||
comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
||||
x: g.pos[0],
|
||||
y: g.pos[1]
|
||||
}))
|
||||
)
|
||||
const positions = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
||||
x: g.pos[0],
|
||||
y: g.pos[1]
|
||||
}))
|
||||
)
|
||||
|
||||
await expect.poll(getGroupPositions).toHaveLength(2)
|
||||
|
||||
await expect(async () => {
|
||||
const positions = await getGroupPositions()
|
||||
expect(Math.abs(positions[0].x - positions[1].x)).toBeCloseTo(50, 0)
|
||||
expect(Math.abs(positions[0].y - positions[1].y)).toBeCloseTo(15, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
expect(positions).toHaveLength(2)
|
||||
const dx = Math.abs(positions[0].x - positions[1].x)
|
||||
const dy = Math.abs(positions[0].y - positions[1].y)
|
||||
expect(dx).toBeCloseTo(50, 0)
|
||||
expect(dy).toBeCloseTo(15, 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } 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'
|
||||
@@ -33,7 +32,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||
expect(await libraryTab.getFolder(groupNodeCategory).count()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
@@ -46,9 +45,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialNodeCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
@@ -59,13 +58,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.click()
|
||||
|
||||
// Verify the node is added to the bookmarks tab
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([groupNodeBookmarkName])
|
||||
expect(
|
||||
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([groupNodeBookmarkName])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||
expect(await libraryTab.getNode(groupNodeName).count()).not.toBe(0)
|
||||
|
||||
// Unbookmark the node
|
||||
await libraryTab
|
||||
@@ -75,11 +72,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.click()
|
||||
|
||||
// Verify the node is removed from the bookmarks tab
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toHaveLength(0)
|
||||
expect(
|
||||
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
@@ -89,9 +84,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
|
||||
await expect(
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
).toBeVisible()
|
||||
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(
|
||||
true
|
||||
)
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
@@ -152,12 +147,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
const manage1 = await group1.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
|
||||
expect(await manage1.getSelectedNodeType()).toBe('g1')
|
||||
await manage1.close()
|
||||
await expect(manage1.root).not.toBeVisible()
|
||||
|
||||
const manage2 = await group2.manageGroupNode()
|
||||
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
|
||||
expect(await manage2.getSelectedNodeType()).toBe('g2')
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
@@ -171,31 +166,24 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
const groupNodeId = 19
|
||||
const groupNodeName = 'two_VAE_decode'
|
||||
|
||||
const totalInputCount = await comfyPage.page.evaluate((nodeName) => {
|
||||
const {
|
||||
extra: { groupNodes }
|
||||
} = window.app!.graph!
|
||||
const { nodes } = groupNodes![nodeName]
|
||||
return nodes.reduce((acc, node) => acc + (node.inputs?.length ?? 0), 0)
|
||||
}, groupNodeName)
|
||||
|
||||
const visibleInputCount = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node!.inputs.length
|
||||
}, groupNodeId)
|
||||
|
||||
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeName) => {
|
||||
const {
|
||||
extra: { groupNodes }
|
||||
} = window.app!.graph!
|
||||
const { nodes } = groupNodes![nodeName]
|
||||
return nodes.reduce(
|
||||
(acc, node) => acc + (node.inputs?.length ?? 0),
|
||||
0
|
||||
)
|
||||
}, groupNodeName)
|
||||
)
|
||||
.toBe(4)
|
||||
expect(totalInputCount).toBe(4)
|
||||
|
||||
// Verify there are 2 visible inputs (2 have been hidden in config)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node!.inputs.length
|
||||
}, groupNodeId)
|
||||
)
|
||||
.toBe(2)
|
||||
expect(visibleInputCount).toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
@@ -222,7 +210,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
// Connect node to group
|
||||
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
||||
const input = await ckpt.connectOutput(0, groupNode, 0)
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
expect(await input.getLinkCount()).toBe(1)
|
||||
// Modify the group node via manage dialog
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await manage.selectNode('KSampler')
|
||||
@@ -231,14 +219,14 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await manage.save()
|
||||
await manage.close()
|
||||
// Ensure the link is still present
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
expect(await input.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
@@ -273,8 +261,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
expect(
|
||||
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
|
||||
).toHaveLength(expectedCount)
|
||||
await expect.poll(() => isRegisteredLitegraph(comfyPage)).toBe(true)
|
||||
await expect.poll(() => isRegisteredNodeDefStore(comfyPage)).toBe(true)
|
||||
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
|
||||
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -345,18 +333,18 @@ 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 expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
|
||||
})
|
||||
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(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)
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,14 +66,11 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const counts = await getSelectionCounts(comfyPage)
|
||||
// Outer Group + Inner Group + 1 node = 3 items
|
||||
await expect
|
||||
.poll(() => getSelectionCounts(comfyPage))
|
||||
.toMatchObject({
|
||||
selectedItemCount: 3,
|
||||
selectedGroupCount: 2,
|
||||
selectedNodeCount: 1
|
||||
})
|
||||
expect(counts.selectedItemCount).toBe(3)
|
||||
expect(counts.selectedGroupCount).toBe(2)
|
||||
expect(counts.selectedNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Setting disabled: clicking outer group selects only the group', async ({
|
||||
@@ -90,13 +87,10 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => getSelectionCounts(comfyPage))
|
||||
.toMatchObject({
|
||||
selectedItemCount: 1,
|
||||
selectedGroupCount: 1,
|
||||
selectedNodeCount: 0
|
||||
})
|
||||
const counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(1)
|
||||
expect(counts.selectedGroupCount).toBe(1)
|
||||
expect(counts.selectedNodeCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Deselecting outer group deselects all children', async ({
|
||||
@@ -114,9 +108,8 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
await comfyPage.canvas.click({ position: outerPos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => getSelectionCounts(comfyPage))
|
||||
.toMatchObject({ selectedItemCount: 3 })
|
||||
let counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(3)
|
||||
|
||||
// Deselect all via page.evaluate to avoid UI overlay interception
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -124,8 +117,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => getSelectionCounts(comfyPage))
|
||||
.toMatchObject({ selectedItemCount: 0 })
|
||||
counts = await getSelectionCounts(comfyPage)
|
||||
expect(counts.selectedItemCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.describe('Image Compare', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
@@ -21,7 +24,12 @@ test.describe('Image Compare', () => {
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
value: {
|
||||
beforeImages: string[]
|
||||
afterImages: string[]
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
@@ -37,6 +45,48 @@ test.describe('Image Compare', () => {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function moveToPercentage(
|
||||
page: Page,
|
||||
containerLocator: Locator,
|
||||
percentage: number
|
||||
) {
|
||||
const box = await containerLocator.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * (percentage / 100),
|
||||
box.y + box.height / 2
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForImagesLoaded(node: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
node.evaluate((el) => {
|
||||
const imgs = el.querySelectorAll('img')
|
||||
return (
|
||||
imgs.length > 0 &&
|
||||
Array.from(imgs).every(
|
||||
(img) => img.complete && img.naturalWidth > 0
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function getClipPathInsetRightPercent(imgLocator: Locator) {
|
||||
return imgLocator.evaluate((el) => {
|
||||
// Accessing raw style avoids cross-browser getComputedStyle normalization issues
|
||||
// Format is uniformly "inset(0 60% 0 0)" per Vue runtime inline style bindings
|
||||
const parts = (el as HTMLElement).style.clipPath.split(' ')
|
||||
return parts.length > 1 ? parseFloat(parts[1]) : -1
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
@@ -50,6 +100,10 @@ test.describe('Image Compare', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -71,11 +125,461 @@ test.describe('Image Compare', () => {
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left),
|
||||
'Slider should default to 50% before screenshot'
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeCloseTo(50, 0)
|
||||
|
||||
await waitForImagesLoaded(node)
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider interaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Mouse hover moves slider position',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 5)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeGreaterThan(90)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeLessThan(10)
|
||||
|
||||
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 95)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeLessThan(10)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeGreaterThan(90)
|
||||
}
|
||||
)
|
||||
|
||||
test('Slider preserves last position when mouse leaves widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
await moveToPercentage(comfyPage.page, afterImg, 30)
|
||||
// Wait for Vue to commit the slider update
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(30, 0)
|
||||
const positionWhileInside = parseFloat(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
|
||||
// Position must not reset to default 50%
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(positionWhileInside, 0)
|
||||
})
|
||||
|
||||
test('Slider clamps to 0% at left edge of container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const box = await afterImg.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
|
||||
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
|
||||
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
|
||||
await expect
|
||||
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
|
||||
.toBe('0%')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single image modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Only before image shows without slider when afterImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Before', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: []
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.locator('[role="presentation"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Only after image shows without slider when beforeImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.locator('[role="presentation"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Batch navigation appears when before side has multiple images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const afterUrl = createTestImageDataUrl('B1', '#888')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
|
||||
await expect(node.locator('[data-testid="batch-nav"]')).toBeVisible()
|
||||
await expect(
|
||||
beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 3')
|
||||
// after-batch renders only when afterBatchCount > 1
|
||||
await expect(
|
||||
node.locator('[data-testid="after-batch"]')
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
beforeBatch.locator('[data-testid="batch-prev"]')
|
||||
).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Batch navigation is hidden when both sides have single images', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Image', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('[data-testid="batch-nav"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Navigate forward through before images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
const counter = beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
const nextBtn = beforeBatch.locator('[data-testid="batch-next"]')
|
||||
const prevBtn = beforeBatch.locator('[data-testid="batch-prev"]')
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
await expect(nextBtn).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Navigate backward through before images', async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
const counter = beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
const nextBtn = beforeBatch.locator('[data-testid="batch-next"]')
|
||||
const prevBtn = beforeBatch.locator('[data-testid="batch-prev"]')
|
||||
|
||||
await nextBtn.click()
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
|
||||
await prevBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
await expect(nextBtn).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Before and after batch navigation are independent', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const urlA = createTestImageDataUrl('B1', '#880')
|
||||
const urlB = createTestImageDataUrl('B2', '#008')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [urlA, urlB]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
const afterBatch = node.locator('[data-testid="after-batch"]')
|
||||
|
||||
await beforeBatch.locator('[data-testid="batch-next"]').click()
|
||||
await afterBatch.locator('[data-testid="batch-next"]').click()
|
||||
|
||||
await expect(
|
||||
beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('2 / 3')
|
||||
await expect(
|
||||
afterBatch.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('2 / 2')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
|
||||
'src',
|
||||
urlB
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual regression screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { pct, expectedClipMin, expectedClipMax } of [
|
||||
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
|
||||
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
|
||||
]) {
|
||||
test(
|
||||
`Screenshot at ${pct}% slider position`,
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await waitForImagesLoaded(node)
|
||||
await moveToPercentage(comfyPage.page, afterImg, pct)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeGreaterThan(expectedClipMin)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeLessThan(expectedClipMax)
|
||||
|
||||
await expect(node).toHaveScreenshot(`image-compare-slider-${pct}.png`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Widget remains stable with broken image URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: ['https://example.invalid/broken.png'],
|
||||
afterImages: ['https://example.invalid/broken2.png']
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(2)
|
||||
await expect(node.locator('[role="presentation"]')).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
node.evaluate((el) => {
|
||||
const imgs = el.querySelectorAll('img')
|
||||
let errors = 0
|
||||
imgs.forEach((img) => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.src) errors++
|
||||
})
|
||||
return errors
|
||||
})
|
||||
)
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Rapid value updates show latest images and reset batch index', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const redUrl = createTestImageDataUrl('Red', '#c00')
|
||||
const green1Url = createTestImageDataUrl('G1', '#0c0')
|
||||
const green2Url = createTestImageDataUrl('G2', '#090')
|
||||
const blueUrl = createTestImageDataUrl('Blue', '#00c')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [redUrl, green1Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await node
|
||||
.locator('[data-testid="before-batch"]')
|
||||
.locator('[data-testid="batch-next"]')
|
||||
.click()
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="before-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('2 / 2')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [green1Url, green2Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
green1Url
|
||||
)
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="before-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 2')
|
||||
})
|
||||
|
||||
test('Legacy string value shows single image without slider', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.locator('[role="presentation"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Custom beforeAlt and afterAlt are used as img alt text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl],
|
||||
beforeAlt: 'Custom before',
|
||||
afterAlt: 'Custom after'
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img[alt="Custom before"]')).toBeVisible()
|
||||
await expect(node.locator('img[alt="Custom after"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
|
||||
const images = Array.from({ length: 20 }, (_, i) =>
|
||||
createTestImageDataUrl(String(i + 1), '#c00')
|
||||
)
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: images,
|
||||
afterImages: images
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="before-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 20')
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="after-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 20')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -11,8 +11,8 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBe(2)
|
||||
|
||||
// Copy the KSampler node (puts data-metadata in clipboard)
|
||||
const ksamplerNodes =
|
||||
@@ -51,9 +51,8 @@ test.describe(
|
||||
|
||||
// Node count should remain the same — stale node metadata should NOT
|
||||
// be deserialized when a media node is selected.
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount)
|
||||
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(finalCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -62,9 +62,9 @@ test.describe('Node Interaction', () => {
|
||||
for (const node of clipNodes) {
|
||||
await node.click('title', { modifiers: [modifier] })
|
||||
}
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(clipNodes.length)
|
||||
const selectedNodeCount =
|
||||
await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedNodeCount).toBe(clipNodes.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,9 +111,9 @@ test.describe('Node Interaction', () => {
|
||||
const clipNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(clipNodes.length)
|
||||
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(
|
||||
clipNodes.length
|
||||
)
|
||||
})
|
||||
|
||||
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
|
||||
@@ -243,7 +243,6 @@ test.describe('Node Interaction', () => {
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
|
||||
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
|
||||
@@ -328,42 +327,18 @@ test.describe('Node Interaction', () => {
|
||||
'Can toggle dom widget node open/closed',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
// Find the node whose collapse toggler matches the hardcoded position.
|
||||
// getNodeRefsByType order is non-deterministic, so identify by proximity.
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
const togglerPos = DefaultGraphPositions.textEncodeNodeToggler
|
||||
let targetNode = nodes[0]
|
||||
let minDist = Infinity
|
||||
for (const n of nodes) {
|
||||
const pos = await n.getPosition()
|
||||
const dist = Math.hypot(pos.x - togglerPos.x, pos.y - togglerPos.y)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
targetNode = n
|
||||
}
|
||||
}
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
|
||||
await comfyPage.canvas.click({
|
||||
position: togglerPos
|
||||
position: DefaultGraphPositions.textEncodeNodeToggler
|
||||
})
|
||||
await expect.poll(() => targetNode.isCollapsed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-off.png'
|
||||
)
|
||||
// Re-expand: clicking the canvas toggler on a collapsed node is
|
||||
// unreliable because DOM widget overlays may intercept the pointer
|
||||
// event. Use programmatic collapse() for the expand step.
|
||||
// TODO(#11006): Restore click-to-expand once DOM widget overlay pointer interception is fixed
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)!
|
||||
node.collapse()
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}, targetNode.id)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
|
||||
// Move mouse away to avoid hover highlight differences.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.delay(1000)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNodeToggler
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-back-open.png'
|
||||
@@ -383,17 +358,14 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
||||
await expect(legacyPrompt).toBeVisible()
|
||||
// LiteGraph's graphdialog has a 256ms dismiss guard (Date.now() - clickTime > 256).
|
||||
// Retry the canvas click until the dialog actually closes.
|
||||
await expect(async () => {
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 10,
|
||||
y: 10
|
||||
}
|
||||
})
|
||||
await expect(legacyPrompt).toBeHidden({ timeout: 500 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
await comfyPage.delay(300)
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 10,
|
||||
y: 10
|
||||
}
|
||||
})
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
|
||||
test('Can close prompt dialog with canvas click (text widget)', async ({
|
||||
@@ -409,17 +381,14 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
||||
await expect(legacyPrompt).toBeVisible()
|
||||
// LiteGraph's graphdialog has a 256ms dismiss guard (Date.now() - clickTime > 256).
|
||||
// Retry the canvas click until the dialog actually closes.
|
||||
await expect(async () => {
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 10,
|
||||
y: 10
|
||||
}
|
||||
})
|
||||
await expect(legacyPrompt).toBeHidden({ timeout: 500 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
await comfyPage.delay(300)
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
x: 10,
|
||||
y: 10
|
||||
}
|
||||
})
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -451,7 +420,7 @@ test.describe('Node Interaction', () => {
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toHaveCount(0)
|
||||
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -481,31 +450,10 @@ test.describe('Node Interaction', () => {
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const group = window.app!.graph.groups[0]
|
||||
return group ? [group.size[0], group.size[1]] : null
|
||||
})
|
||||
)
|
||||
.not.toBeNull()
|
||||
|
||||
const initialGroupSize = await comfyPage.page.evaluate(() => {
|
||||
const group = window.app!.graph.groups[0]
|
||||
return group ? [group.size[0], group.size[1]] : null
|
||||
})
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const group = window.app!.graph.groups[0]
|
||||
return group ? [group.size[0], group.size[1]] : null
|
||||
})
|
||||
)
|
||||
.not.toEqual(initialGroupSize)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-fit-to-contents.png'
|
||||
)
|
||||
@@ -514,19 +462,15 @@ test.describe('Node Interaction', () => {
|
||||
|
||||
test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
|
||||
)[0]
|
||||
|
||||
await comfyPage.command.executeCommand(
|
||||
'Comfy.Canvas.ToggleSelectedNodes.Pin'
|
||||
)
|
||||
await expect.poll(() => nodeRef.isPinned()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-pinned.png')
|
||||
await comfyPage.command.executeCommand(
|
||||
'Comfy.Canvas.ToggleSelectedNodes.Pin'
|
||||
)
|
||||
await expect.poll(() => nodeRef.isPinned()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
|
||||
})
|
||||
|
||||
@@ -535,15 +479,11 @@ test.describe('Node Interaction', () => {
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
|
||||
)[0]
|
||||
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
|
||||
}
|
||||
)
|
||||
@@ -642,23 +582,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
}
|
||||
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await expect.poll(() => getCursorStyle()).toBe('default')
|
||||
expect(await getCursorStyle()).toBe('default')
|
||||
await comfyPage.page.mouse.down()
|
||||
await expect.poll(() => getCursorStyle()).toBe('grabbing')
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
// Move mouse should not alter cursor style.
|
||||
await comfyPage.page.mouse.move(10, 20)
|
||||
await expect.poll(() => getCursorStyle()).toBe('grabbing')
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
await comfyPage.page.mouse.up()
|
||||
await expect.poll(() => getCursorStyle()).toBe('default')
|
||||
expect(await getCursorStyle()).toBe('default')
|
||||
|
||||
await comfyPage.page.keyboard.down('Space')
|
||||
await expect.poll(() => getCursorStyle()).toBe('grab')
|
||||
expect(await getCursorStyle()).toBe('grab')
|
||||
await comfyPage.page.mouse.down()
|
||||
await expect.poll(() => getCursorStyle()).toBe('grabbing')
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
await comfyPage.page.mouse.up()
|
||||
await expect.poll(() => getCursorStyle()).toBe('grab')
|
||||
expect(await getCursorStyle()).toBe('grab')
|
||||
await comfyPage.page.keyboard.up('Space')
|
||||
await expect.poll(() => getCursorStyle()).toBe('default')
|
||||
expect(await getCursorStyle()).toBe('default')
|
||||
})
|
||||
|
||||
// https://github.com/Comfy-Org/litegraph.js/pull/424
|
||||
@@ -675,27 +615,27 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
|
||||
// Initial state check
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await expect.poll(() => getCursorStyle()).toBe('default')
|
||||
expect(await getCursorStyle()).toBe('default')
|
||||
|
||||
// Click and hold
|
||||
await comfyPage.page.mouse.down()
|
||||
await expect.poll(() => getCursorStyle()).toBe('grabbing')
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
|
||||
// Press space while holding click
|
||||
await comfyPage.page.keyboard.down('Space')
|
||||
await expect.poll(() => getCursorStyle()).toBe('grabbing')
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
|
||||
// Release click while space is still down
|
||||
await comfyPage.page.mouse.up()
|
||||
await expect.poll(() => getCursorStyle()).toBe('grab')
|
||||
expect(await getCursorStyle()).toBe('grab')
|
||||
|
||||
// Release space
|
||||
await comfyPage.page.keyboard.up('Space')
|
||||
await expect.poll(() => getCursorStyle()).toBe('default')
|
||||
expect(await getCursorStyle()).toBe('default')
|
||||
|
||||
// Move mouse - cursor should remain default
|
||||
await comfyPage.page.mouse.move(20, 20)
|
||||
await expect.poll(() => getCursorStyle()).toBe('default')
|
||||
expect(await getCursorStyle()).toBe('default')
|
||||
})
|
||||
|
||||
test('Can pan when dragging a link', async ({ comfyPage, comfyMouse }) => {
|
||||
@@ -786,14 +726,11 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.openWorkflows.length
|
||||
})
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
const openCount = await comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.openWorkflows.length
|
||||
})
|
||||
expect(openCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Restore workflow on reload (switch workflow)', async ({
|
||||
@@ -879,22 +816,14 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.topbar.getTabNames())
|
||||
.poll(() => comfyPage.menu.topbar.getTabNames(), { timeout: 5000 })
|
||||
.toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
return (
|
||||
tabs.indexOf(workflowA) < tabs.indexOf(workflowB) &&
|
||||
tabs.indexOf(workflowA) >= 0
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
|
||||
await expect(comfyPage.menu.topbar.getActiveTab()).toContainText(
|
||||
workflowB
|
||||
)
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
test('Restores sidebar workflows after reload', async ({ comfyPage }) => {
|
||||
@@ -903,18 +832,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const ws = await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
return ws.indexOf(workflowA) < ws.indexOf(workflowB)
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(comfyPage.menu.workflowsTab.activeWorkflowLabel).toHaveText(
|
||||
workflowB
|
||||
const openWorkflows =
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -964,20 +892,12 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
return (
|
||||
tabs.includes(workflowA) &&
|
||||
tabs.includes(workflowB) &&
|
||||
tabs.indexOf(workflowA) < tabs.indexOf(workflowB)
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
await expect(comfyPage.menu.topbar.getActiveTab()).toContainText(
|
||||
workflowB
|
||||
)
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
test('Restores sidebar workflows after browser restart', async ({
|
||||
@@ -988,18 +908,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const ws = await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
return ws.indexOf(workflowA) < ws.indexOf(workflowB)
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(comfyPage.menu.workflowsTab.activeWorkflowLabel).toHaveText(
|
||||
workflowB
|
||||
const openWorkflows =
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1025,7 +944,7 @@ test.describe('Load duplicate workflow', () => {
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1156,9 +1075,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedCount).toBe(1)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-click-node-select.png'
|
||||
)
|
||||
@@ -1193,9 +1111,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(clipNodes.length)
|
||||
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedCount).toBe(clipNodes.length)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-left-drag-select.png'
|
||||
)
|
||||
@@ -1238,9 +1155,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedCount).toBe(1)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-click-node-select.png'
|
||||
)
|
||||
@@ -1281,14 +1197,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
const selectedCountAfterDrag =
|
||||
await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedCountAfterDrag).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(0)
|
||||
const selectedCountAfterClear =
|
||||
await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedCountAfterClear).toBe(0)
|
||||
|
||||
await comfyPage.page.keyboard.down('Space')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
@@ -1303,9 +1219,9 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Space')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(0)
|
||||
const selectedCountAfterSpaceDrag =
|
||||
await comfyPage.nodeOps.getSelectedGraphNodesCount()
|
||||
expect(selectedCountAfterSpaceDrag).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1389,7 +1305,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
await comfyPage.page.mouse.move(50, 50)
|
||||
await comfyPage.page.mouse.down()
|
||||
await expect.poll(() => getCursorStyle()).toBe('grabbing')
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
await comfyPage.page.mouse.up()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
@@ -83,10 +83,9 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
|
||||
)
|
||||
await action.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>('Comfy.Queue.ShowRunProgressBar')
|
||||
)
|
||||
.toBe(!settingBefore)
|
||||
const settingAfter = await comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.Queue.ShowRunProgressBar'
|
||||
)
|
||||
expect(settingAfter).toBe(!settingBefore)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,9 +18,9 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
await textBox.click()
|
||||
await textBox.fill('k')
|
||||
await expect(textBox).toHaveValue('k')
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
.toBe(undefined)
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test('Should not trigger modifier keybinding when typing in input fields', async ({
|
||||
@@ -35,9 +35,7 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
await textBox.fill('q')
|
||||
await textBox.press('Control+k')
|
||||
await expect(textBox).toHaveValue('q')
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
.toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
|
||||
})
|
||||
|
||||
test('Should not trigger keybinding reserved by text input when typing in input fields', async ({
|
||||
@@ -51,8 +49,8 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
await textBox.click()
|
||||
await textBox.press('Control+v')
|
||||
await expect(textBox).toBeFocused()
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
||||
.toBe(undefined)
|
||||
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class Load3DHelper {
|
||||
constructor(readonly node: Locator) {}
|
||||
|
||||
@@ -22,10 +19,6 @@ export class Load3DHelper {
|
||||
return this.node.locator('input[type="color"]')
|
||||
}
|
||||
|
||||
get openViewerButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /open in 3d viewer/i })
|
||||
}
|
||||
|
||||
getUploadButton(label: string): Locator {
|
||||
return this.node.getByText(label)
|
||||
}
|
||||
@@ -44,10 +37,4 @@ export class Load3DHelper {
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}, hex)
|
||||
}
|
||||
|
||||
async waitForModelLoaded(): Promise<void> {
|
||||
await expect(this.node.getByTestId(TestIds.loading.overlay)).toBeHidden({
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class Load3DViewerHelper {
|
||||
readonly dialog: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.dialog = page.locator('[aria-labelledby="global-load3d-viewer"]')
|
||||
}
|
||||
|
||||
get canvas(): Locator {
|
||||
return this.dialog.locator('canvas')
|
||||
}
|
||||
|
||||
get sidebar(): Locator {
|
||||
return this.dialog.getByTestId('load3d-viewer-sidebar')
|
||||
}
|
||||
|
||||
get cancelButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: /cancel/i })
|
||||
}
|
||||
|
||||
async waitForOpen(): Promise<void> {
|
||||
await expect(this.dialog).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
async waitForClosed(): Promise<void> {
|
||||
await expect(this.dialog).toBeHidden({ timeout: 5000 })
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,31 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { Load3DHelper } from './Load3DHelper'
|
||||
|
||||
test.describe('Load3D', () => {
|
||||
let load3d: Load3DHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
load3d = new Load3DHelper(node)
|
||||
})
|
||||
|
||||
test(
|
||||
'Renders canvas with upload buttons and controls menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ load3d }) => {
|
||||
async () => {
|
||||
await expect(load3d.node).toBeVisible()
|
||||
|
||||
await expect(load3d.canvas).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await load3d.canvas.boundingBox()
|
||||
return b?.width ?? 0
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await load3d.canvas.boundingBox()
|
||||
return b?.height ?? 0
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
const canvasBox = await load3d.canvas.boundingBox()
|
||||
expect(canvasBox!.width).toBeGreaterThan(0)
|
||||
expect(canvasBox!.height).toBeGreaterThan(0)
|
||||
|
||||
await expect(load3d.getUploadButton('upload 3d model')).toBeVisible()
|
||||
await expect(
|
||||
@@ -42,7 +44,7 @@ test.describe('Load3D', () => {
|
||||
test(
|
||||
'Controls menu opens and shows all categories',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ load3d }) => {
|
||||
async () => {
|
||||
await load3d.openMenu()
|
||||
|
||||
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
|
||||
@@ -61,7 +63,7 @@ test.describe('Load3D', () => {
|
||||
test(
|
||||
'Changing background color updates the scene',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
async ({ comfyPage }) => {
|
||||
await load3d.setBackgroundColor('#cc3333')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -88,72 +90,8 @@ test.describe('Load3D', () => {
|
||||
test(
|
||||
'Recording controls are visible for Load3D',
|
||||
{ tag: '@smoke' },
|
||||
async ({ load3d }) => {
|
||||
async () => {
|
||||
await expect(load3d.recordingButton).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Uploads a 3D model via button and renders it',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
const uploadResponsePromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
|
||||
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
|
||||
await load3d.getUploadButton('upload 3d model').click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(assetPath('cube.obj'))
|
||||
|
||||
await uploadResponsePromise
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const modelFileWidget = await node.getWidget(0)
|
||||
await expect
|
||||
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
|
||||
.toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-uploaded-cube-obj.png',
|
||||
{ maxDiffPixelRatio: 0.1 }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drag-and-drops a 3D model onto the canvas',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
const canvasBox = await load3d.canvas.boundingBox()
|
||||
expect(canvasBox, 'Canvas bounding box should exist').not.toBeNull()
|
||||
const dropPosition = {
|
||||
x: canvasBox!.x + canvasBox!.width / 2,
|
||||
y: canvasBox!.y + canvasBox!.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('cube.obj', {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const modelFileWidget = await node.getWidget(0)
|
||||
await expect
|
||||
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
|
||||
.toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-dropped-cube-obj.png',
|
||||
{ maxDiffPixelRatio: 0.1 }
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 36 KiB |
@@ -1,56 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { load3dViewerTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
test.describe('Load3D Viewer', () => {
|
||||
test.beforeEach(async ({ comfyPage, load3d }) => {
|
||||
// Upload cube.obj so the node has a model loaded
|
||||
const uploadResponsePromise = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
|
||||
await load3d.getUploadButton('upload 3d model').click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(assetPath('cube.obj'))
|
||||
await uploadResponsePromise
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
const modelFileWidget = await nodeRef.getWidget(0)
|
||||
await expect
|
||||
.poll(() => modelFileWidget.getValue(), { timeout: 5000 })
|
||||
.toContain('cube.obj')
|
||||
|
||||
await load3d.waitForModelLoaded()
|
||||
})
|
||||
|
||||
test(
|
||||
'Opens viewer dialog with canvas and controls sidebar',
|
||||
{ tag: '@smoke' },
|
||||
async ({ load3d, viewer }) => {
|
||||
await load3d.openViewerButton.click()
|
||||
await viewer.waitForOpen()
|
||||
|
||||
await expect(viewer.canvas).toBeVisible()
|
||||
const canvasBox = await viewer.canvas.boundingBox()
|
||||
expect(canvasBox!.width).toBeGreaterThan(0)
|
||||
expect(canvasBox!.height).toBeGreaterThan(0)
|
||||
|
||||
await expect(viewer.sidebar).toBeVisible()
|
||||
await expect(viewer.cancelButton).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Cancel button closes the viewer dialog',
|
||||
{ tag: '@smoke' },
|
||||
async ({ load3d, viewer }) => {
|
||||
await load3d.openViewerButton.click()
|
||||
await viewer.waitForOpen()
|
||||
|
||||
await viewer.cancelButton.click()
|
||||
await viewer.waitForClosed()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -60,17 +60,11 @@ test.describe(
|
||||
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropURL(url)
|
||||
|
||||
// The drop triggers an async fetch → parse → loadGraphData chain.
|
||||
// Poll until the graph settles with the loaded workflow's nodes.
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
|
||||
timeout: 15000
|
||||
})
|
||||
.toBeGreaterThan(initialNodeCount)
|
||||
const readableName = url.split('/').pop()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`dropped_workflow_url_${readableName}.png`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,12 +84,12 @@ test.describe(
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
await expect.poll(() => node.boundingBox()).toBeTruthy()
|
||||
const box = (await node.boundingBox())!
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
|
||||
const dropPosition = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
|
||||
@@ -109,9 +103,8 @@ test.describe(
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.not.toBe(initialNodeCount)
|
||||
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newNodeCount).not.toBe(initialNodeCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
After Width: | Height: | Size: 135 KiB |
@@ -13,14 +13,6 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
// Load a workflow with some nodes to render
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Should start at normal zoom (not low quality)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
|
||||
.toBe(false)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
|
||||
.toBeCloseTo(1, 1)
|
||||
|
||||
// Get initial LOD state and settings
|
||||
const initialState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
@@ -31,55 +23,62 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Should start at normal zoom (not low quality)
|
||||
expect(initialState.lowQuality).toBe(false)
|
||||
expect(initialState.scale).toBeCloseTo(1, 1)
|
||||
|
||||
// Calculate expected threshold (8px / 14px ≈ 0.571)
|
||||
const expectedThreshold = initialState.minFontSize / 14
|
||||
// Can't access private _lowQualityZoomThreshold directly
|
||||
|
||||
// Zoom out just above threshold (should still be high quality)
|
||||
await comfyPage.canvasOps.zoom(120, 5) // Zoom out 5 steps
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return { lowQuality: canvas.low_quality, scale: canvas.ds.scale }
|
||||
})
|
||||
)
|
||||
.toMatchObject({ lowQuality: false })
|
||||
const aboveThresholdState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
|
||||
// If still above threshold, should be high quality
|
||||
if (aboveThresholdState.scale > expectedThreshold) {
|
||||
expect(aboveThresholdState.lowQuality).toBe(false)
|
||||
}
|
||||
|
||||
// Zoom out more to trigger LOD (below threshold)
|
||||
await comfyPage.canvasOps.zoom(120, 5) // Zoom out 5 more steps
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return { lowQuality: canvas.low_quality, scale: canvas.ds.scale }
|
||||
})
|
||||
)
|
||||
.toMatchObject({ lowQuality: true })
|
||||
// Check that LOD is now active
|
||||
const zoomedOutState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
|
||||
.toBeLessThan(expectedThreshold)
|
||||
expect(zoomedOutState.scale).toBeLessThan(expectedThreshold)
|
||||
expect(zoomedOutState.lowQuality).toBe(true)
|
||||
|
||||
// Zoom back in to disable LOD (above threshold)
|
||||
await comfyPage.canvasOps.zoom(-120, 15) // Zoom in 15 steps
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return { lowQuality: canvas.low_quality, scale: canvas.ds.scale }
|
||||
})
|
||||
)
|
||||
.toMatchObject({ lowQuality: false })
|
||||
// Check that LOD is now inactive
|
||||
const zoomedInState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
|
||||
.toBeGreaterThan(expectedThreshold)
|
||||
expect(zoomedInState.scale).toBeGreaterThan(expectedThreshold)
|
||||
expect(zoomedInState.lowQuality).toBe(false)
|
||||
})
|
||||
|
||||
test('Should update threshold when font size setting changes', async ({
|
||||
@@ -94,28 +93,36 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
)
|
||||
|
||||
// Check that font size updated
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.min_font_size_for_lod)
|
||||
)
|
||||
.toBe(14)
|
||||
const newState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
minFontSize: canvas.min_font_size_for_lod
|
||||
}
|
||||
})
|
||||
|
||||
expect(newState.minFontSize).toBe(14)
|
||||
// Expected threshold would be 14px / 14px = 1.0
|
||||
|
||||
// At default zoom, LOD should still be inactive (scale is exactly 1.0, not less than)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
|
||||
.toBe(false)
|
||||
const lodState = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.low_quality
|
||||
})
|
||||
expect(lodState).toBe(false)
|
||||
|
||||
// Zoom out slightly to trigger LOD
|
||||
await comfyPage.canvasOps.zoom(120, 1) // Zoom out 1 step
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
|
||||
.toBe(true)
|
||||
const afterZoom = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
|
||||
.toBeLessThan(1.0)
|
||||
expect(afterZoom.scale).toBeLessThan(1.0)
|
||||
expect(afterZoom.lowQuality).toBe(true)
|
||||
})
|
||||
|
||||
test('Should disable LOD when font size is set to 0', async ({
|
||||
@@ -131,19 +138,18 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// LOD should remain disabled even at very low zoom
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.min_font_size_for_lod)
|
||||
)
|
||||
.toBe(0)
|
||||
const state = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale,
|
||||
minFontSize: canvas.min_font_size_for_lod
|
||||
}
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.low_quality))
|
||||
.toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
|
||||
.toBeLessThan(0.2) // Very zoomed out
|
||||
expect(state.minFontSize).toBe(0) // LOD disabled
|
||||
expect(state.lowQuality).toBe(false)
|
||||
expect(state.scale).toBeLessThan(0.2) // Very zoomed out
|
||||
})
|
||||
|
||||
test(
|
||||
@@ -163,39 +169,41 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
}, targetZoom)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for LOD to activate before taking screenshot
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.low_quality)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Take snapshot with LOD active (default 8px setting)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'lod-comparison-low-quality.png'
|
||||
)
|
||||
|
||||
const lowQualityState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
expect(lowQualityState.lowQuality).toBe(true)
|
||||
|
||||
// Disable LOD to see high quality at same zoom
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD',
|
||||
0
|
||||
)
|
||||
|
||||
// Wait for LOD to deactivate after setting change
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.low_quality)
|
||||
)
|
||||
.toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Take snapshot with LOD disabled (full quality at same zoom)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'lod-comparison-high-quality.png'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.canvas.ds.scale))
|
||||
.toBeCloseTo(targetZoom, 2)
|
||||
const highQualityState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
expect(highQualityState.lowQuality).toBe(false)
|
||||
expect(highQualityState.scale).toBeCloseTo(targetZoom, 2)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -22,7 +22,10 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
await expect(comfyPage.menu.buttons).toHaveCount(initialChildrenCount + 1)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newChildrenCount = await comfyPage.menu.buttons.count()
|
||||
expect(newChildrenCount).toBe(initialChildrenCount + 1)
|
||||
})
|
||||
|
||||
test.describe('Workflows topbar tabs', () => {
|
||||
@@ -35,17 +38,15 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Can show opened workflows', async ({ comfyPage }) => {
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.topbar.getTabNames())
|
||||
.toEqual(['Unsaved Workflow'])
|
||||
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
|
||||
'Unsaved Workflow'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can close saved-workflow tabs', async ({ comfyPage }) => {
|
||||
const workflowName = `tempWorkflow-${test.info().title}`
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.topbar.getTabNames())
|
||||
.toEqual([workflowName])
|
||||
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
|
||||
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.topbar.getTabNames())
|
||||
@@ -61,11 +62,10 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
const topLevelMenuItem = comfyPage.page
|
||||
.locator('a.p-menubar-item-link')
|
||||
.first()
|
||||
await expect
|
||||
.poll(() =>
|
||||
topLevelMenuItem.evaluate((el) => el.scrollWidth > el.clientWidth)
|
||||
)
|
||||
.toBe(false)
|
||||
const isTextCutoff = await topLevelMenuItem.evaluate((el) => {
|
||||
return el.scrollWidth > el.clientWidth
|
||||
})
|
||||
expect(isTextCutoff).toBe(false)
|
||||
})
|
||||
|
||||
test('Clicking on active state items does not close menu', async ({
|
||||
@@ -148,7 +148,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
const exportTag = comfyPage.page.locator('.keybinding-tag', {
|
||||
hasText: 'Ctrl + s'
|
||||
})
|
||||
await expect(exportTag).toHaveCount(1)
|
||||
expect(await exportTag.count()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can catch error when executing command', async ({ comfyPage }) => {
|
||||
@@ -173,7 +173,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
|
||||
@@ -205,9 +205,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeSubmenu).toBeVisible()
|
||||
await expect(lightThemeItem.locator('.pi-check')).not.toHaveClass(
|
||||
/invisible/
|
||||
)
|
||||
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// Screenshot with light theme active
|
||||
@@ -232,11 +230,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
await expect(async () => {
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(themeItems2.submenu).toBeVisible()
|
||||
await expect(
|
||||
themeItems2.darkTheme.locator('.pi-check')
|
||||
).not.toHaveClass(/invisible/)
|
||||
await expect(themeItems2.lightTheme.locator('.pi-check')).toHaveClass(
|
||||
/invisible/
|
||||
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
|
||||
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(
|
||||
false
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
@@ -260,9 +256,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.UseNewMenu'))
|
||||
.toBe('Top')
|
||||
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
|
||||
'Top'
|
||||
)
|
||||
})
|
||||
|
||||
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
|
||||
@@ -270,9 +266,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
|
||||
await comfyPage.setup()
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.UseNewMenu'))
|
||||
.toBe('Top')
|
||||
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
|
||||
'Top'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,9 +53,13 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -65,9 +69,13 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -100,7 +108,6 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
@@ -115,18 +122,20 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expect(async () => {
|
||||
const vb = await viewport.boundingBox()
|
||||
const mb = await minimap.boundingBox()
|
||||
expect(vb).toBeTruthy()
|
||||
expect(mb).toBeTruthy()
|
||||
expect(vb!.width).toBeGreaterThan(0)
|
||||
expect(vb!.height).toBeGreaterThan(0)
|
||||
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
|
||||
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
|
||||
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
|
||||
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
|
||||
}).toPass({ timeout: 5000 })
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ test.describe(
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
@@ -82,13 +82,7 @@ test.describe(
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode',
|
||||
mode
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeBadge.NodeIdBadgeMode'),
|
||||
{ message: 'NodeIdBadgeMode setting should be applied' }
|
||||
)
|
||||
.toBe(mode)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`node-badge-${mode}.png`
|
||||
@@ -110,11 +104,8 @@ test.describe(
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'unknown')
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'), {
|
||||
message: 'ColorPalette setting should be applied'
|
||||
})
|
||||
.toBe('unknown')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-unknown-color-palette.png'
|
||||
@@ -129,11 +120,8 @@ test.describe(
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'), {
|
||||
message: 'ColorPalette setting should be applied'
|
||||
})
|
||||
.toBe('light')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-light-color-palette.png'
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Node context menu viewport overflow (#10824)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
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')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler toward the lower-left so the menu has limited space below it.
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()!
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height * 0.75
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
test('last menu item "Remove" is reachable via scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
|
||||
{
|
||||
message:
|
||||
'Menu should overflow vertically so this test exercises the viewport clamp',
|
||||
timeout: 3000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// "Remove" is the last item in the More Options menu.
|
||||
// It must become reachable by scrolling the bounded menu list.
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
const didScroll = await rootList.evaluate((el) => {
|
||||
const previousScrollTop = el.scrollTop
|
||||
el.scrollTo({ top: el.scrollHeight })
|
||||
return el.scrollTop > previousScrollTop
|
||||
})
|
||||
expect(didScroll).toBe(true)
|
||||
await expect(removeItem).toBeVisible()
|
||||
})
|
||||
|
||||
test('last menu item "Remove" is clickable and removes the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
await removeItem.scrollIntoViewIfNeeded()
|
||||
await removeItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The node should be removed from the graph
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -37,7 +37,7 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
|
||||
test('Only optional inputs', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/only_optional_inputs')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
|
||||
@@ -433,7 +433,8 @@ This is documentation for a custom node.
|
||||
const imageCount = await images.count()
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const img = images.nth(i)
|
||||
await expect(img).not.toHaveAttribute('onerror')
|
||||
const onError = await img.getAttribute('onerror')
|
||||
expect(onError).toBeNull()
|
||||
}
|
||||
|
||||
// Check that javascript: links are sanitized
|
||||
@@ -441,7 +442,10 @@ This is documentation for a custom node.
|
||||
const linkCount = await links.count()
|
||||
for (let i = 0; i < linkCount; i++) {
|
||||
const link = links.nth(i)
|
||||
await expect(link).not.toHaveAttribute('href', /^javascript:/i)
|
||||
const href = await link.getAttribute('href')
|
||||
if (href !== null) {
|
||||
expect(href).not.toContain('javascript:')
|
||||
}
|
||||
}
|
||||
|
||||
// Safe content should remain
|
||||
@@ -508,7 +512,8 @@ This is English documentation.
|
||||
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
|
||||
|
||||
// Should show some content even on error
|
||||
await expect(helpPage).not.toHaveText('')
|
||||
const content = await helpPage.textContent()
|
||||
expect(content).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Should update help content when switching between nodes', async ({
|
||||
|
||||
@@ -82,7 +82,9 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await expect(firstCard).toHaveAttribute('data-node-name', /.+/)
|
||||
const nodeName = await firstCard.getAttribute('data-node-name')
|
||||
expect(nodeName).toBeTruthy()
|
||||
expect(nodeName!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
|
||||
@@ -31,9 +31,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add first default result with Enter', async ({ comfyPage }) => {
|
||||
@@ -50,9 +49,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
@@ -83,7 +81,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
|
||||
const count = await searchBoxV2.results.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,9 +142,8 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,9 +41,8 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount)
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('Search clears when reopening', async ({ comfyPage }) => {
|
||||
@@ -76,10 +75,9 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
|
||||
await searchBoxV2.categoryButton('loaders').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const loaderResults = await searchBoxV2.results.allTextContents()
|
||||
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.allTextContents())
|
||||
.not.toEqual(samplingResults)
|
||||
expect(samplingResults).not.toEqual(loaderResults)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,9 +107,8 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
await expect(filterChip).toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.allTextContents())
|
||||
.not.toEqual(unfilteredResults)
|
||||
const filteredResults = await searchBoxV2.results.allTextContents()
|
||||
expect(filteredResults).not.toEqual(unfilteredResults)
|
||||
|
||||
// Remove filter by clicking the chip delete button
|
||||
await filterChip.getByTestId('chip-delete').click()
|
||||
|
||||
@@ -39,21 +39,18 @@ test.describe('Painter', () => {
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
const isEmptyBefore = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
.toBe(true)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
expect(isEmptyBefore).toBe(true)
|
||||
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
@@ -71,24 +68,23 @@ test.describe('Painter', () => {
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(async () => {
|
||||
const hasContent = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasContent).toBe(true)
|
||||
}).toPass()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
|
||||
@@ -327,7 +327,8 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
// Verify we actually entered the culling regime.
|
||||
// isNodeTooSmall triggers when max(width, height) * scale < 4px.
|
||||
// Typical nodes are ~200px wide, so scale must be < 0.02.
|
||||
await expect.poll(() => comfyPage.canvasOps.getScale()).toBeLessThan(0.02)
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
expect(scale).toBeLessThan(0.02)
|
||||
|
||||
// Idle at extreme zoom-out — most nodes should be culled
|
||||
for (let i = 0; i < 60; i++) {
|
||||
@@ -357,11 +358,9 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
|
||||
// Wait for the output widget to populate (execution_success)
|
||||
const outputNode = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
await expect
|
||||
.poll(async () => (await outputNode.getWidget(0)).getValue(), {
|
||||
timeout: 10000
|
||||
})
|
||||
.toBe('foo')
|
||||
await expect(async () => {
|
||||
expect(await (await outputNode.getWidget(0)).getValue()).toBe('foo')
|
||||
}).toPass({ timeout: 10000 })
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('workflow-execution')
|
||||
recordMeasurement(m)
|
||||
|
||||