Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
c4468a14c5 fix(cloud): disable legacy node templates feature on cloud
The legacy node templates feature (accessed via canvas right-click menu)
causes false-positive errors on cloud. This change conditionally loads
the extension only for non-cloud builds.

Refs: COM-14105
Amp-Thread-ID: https://ampcode.com/threads/T-019c0c5f-e384-7733-9081-9460dc8debf5
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 18:07:18 -08:00
1168 changed files with 18065 additions and 82039 deletions

View File

@@ -391,7 +391,7 @@ echo "Last stable release: $LAST_STABLE"
```bash
# Trigger the workflow
gh workflow run release-version-bump.yaml -f version_type=${VERSION_TYPE}
gh workflow run version-bump.yaml -f version_type=${VERSION_TYPE}
# Workflow runs quickly - usually creates PR within 30 seconds
echo "Workflow triggered. Waiting for PR creation..."
@@ -443,21 +443,28 @@ echo "Workflow triggered. Waiting for PR creation..."
gh pr view ${PR_NUMBER} --json labels | jq -r '.labels[].name' | grep -q "Release" || \
echo "ERROR: Release label missing! Add it immediately!"
```
2. Verify version number in package.json
3. Review all changed files
4. Ensure no unintended changes included
5. Wait for required PR checks:
2. Check for update-locales commits:
```bash
# WARNING: update-locales may add [skip ci] which blocks release workflow!
gh pr view ${PR_NUMBER} --json commits | grep -q "skip ci" && \
echo "WARNING: [skip ci] detected - release workflow may not trigger!"
```
3. Verify version number in package.json
4. Review all changed files
5. Ensure no unintended changes included
6. Wait for required PR checks:
```bash
gh pr checks ${PR_NUMBER} --watch
```
6. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks
3. Check no new commits to main since PR creation
4. **DEPLOYMENT READINESS**: Ready to merge?
2. Monitor CI checks - watch for update-locales
3. **CRITICAL WARNING**: If update-locales adds [skip ci], the release workflow won't trigger!
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release

View File

@@ -1,200 +0,0 @@
---
name: writing-playwright-tests
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
---
# Writing Playwright Tests for ComfyUI_frontend
## Golden Rules
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
- Assets live in `browser_tests/assets/`
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
## Vue Nodes vs LiteGraph: Decision Guide
Choose based on **what you're testing**, not personal preference:
| Testing... | Use | Why |
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
| Both in same test | Pick primary, minimize switching | Avoid confusion |
**Vue Nodes requires explicit opt-in:**
```typescript
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
```
**Vue Node state uses CSS classes:**
```typescript
const BYPASS_CLASS = /before:bg-bypass\/60/
await expect(node).toHaveClass(BYPASS_CLASS)
```
## Common Issues
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
| Symptom | Common Cause | Typical Fix |
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
## Test Tags
Add appropriate tags to every test:
| Tag | When to Use |
| ------------- | ----------------------------------------- |
| `@smoke` | Quick essential tests |
| `@slow` | Tests > 10 seconds |
| `@screenshot` | Visual regression tests |
| `@canvas` | Canvas interactions |
| `@node` | Node-related |
| `@widget` | Widget-related |
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
| `@2x` | HiDPI tests (runs on 2x scale project) |
```typescript
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
```
## Retry Patterns
**Never use `waitForTimeout`** - it's always wrong.
| Pattern | Use Case |
| ------------------------ | ---------------------------------------------------- |
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
| `expect.poll()` | Single value polling |
| `expect().toPass()` | Multiple assertions that must all pass |
```typescript
// Prefer auto-retrying assertions when possible
await expect(node).toBeVisible()
// Single value polling
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
// Multiple conditions
await expect(async () => {
expect(await node1.getValue()).toBe('foo')
expect(await node2.getValue()).toBe('bar')
}).toPass({ timeout: 2000 })
```
## Screenshot Baselines
- **Screenshots are Linux-only.** Don't commit local screenshots.
- **To update baselines:** Add PR label `New Browser Test Expectations`
- **Mask dynamic content:**
```typescript
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
mask: [page.locator('.timestamp')]
})
```
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `npx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`
## Anti-Patterns
Avoid these common mistakes:
1. **Arbitrary waits** - Use retrying assertions instead
```typescript
// ❌ await page.waitForTimeout(500)
// ✅ await expect(element).toBeVisible()
```
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
```typescript
// ❌ page.locator('div.container > button.btn-primary')
// ✅ page.getByTestId('submit-button')
```
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
```typescript
await node.drag({ x: 50, y: 50 })
await comfyPage.nextFrame() // Required
```
4. **Shared state between tests** - Tests must be independent
```typescript
// ❌ let sharedData // Outside test
// ✅ Define state inside each test
```
## Quick Start Template
```typescript
// Path depends on test file location - adjust '../' segments accordingly
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('should do something', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('myWorkflow')
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
// ... test logic
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
})
})
```
## Finding Patterns
```bash
# Find similar tests
grep -r "KSampler" browser_tests/tests/
# Find usage of a fixture method
grep -r "loadWorkflow" browser_tests/tests/
# Find tests with specific tag
grep -r '@screenshot' browser_tests/tests/
```
## Key Files to Read
| Purpose | Path |
| ----------------- | ------------------------------------------ |
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
| Helper classes | `browser_tests/fixtures/helpers/` |
| Component objects | `browser_tests/fixtures/components/` |
| Test selectors | `browser_tests/fixtures/selectors.ts` |
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
| Test assets | `browser_tests/assets/` |
| Existing tests | `browser_tests/tests/` |
**Read the fixture code directly** - it's the source of truth for available methods.

View File

@@ -1,7 +0,0 @@
issue_enrichment:
auto_enrich:
enabled: true
reviews:
high_level_summary: false
auto_review:
drafts: true

16
.gitattributes vendored
View File

@@ -1,5 +1,17 @@
# Force all text files to use LF line endings
* text=auto eol=lf
# Default
* text=auto
# Force TS to LF to make the unixy scripts not break on Windows
*.cjs text eol=lf
*.js text eol=lf
*.json text eol=lf
*.mjs text eol=lf
*.mts text eol=lf
*.snap text eol=lf
*.ts text eol=lf
*.vue text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true

View File

@@ -10,7 +10,10 @@ body:
options:
- label: I am running the latest version of ComfyUI
required: true
- label: I have custom nodes enabled
- label: I have searched existing issues to make sure this isn't a duplicate
required: true
- label: I have tested with all custom nodes disabled ([see how](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled))
required: true
- type: textarea
id: description

View File

@@ -4,6 +4,13 @@ labels: []
type: Feature
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the problem you're experiencing, and that it's not addressed in a recent build/commit.
options:
- label: I have searched the existing issues and checked the recent builds/commits
required: true
- type: markdown
attributes:
value: |

View File

@@ -1,81 +0,0 @@
name: 'CI: Dist Telemetry Scan'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
env:
DISTRIBUTION: localhost
- name: Scan dist for GTM telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Google Tag Manager references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'Google Tag Manager' \
-e '(?i)\bgtm\.js\b' \
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
dist; then
echo '❌ ERROR: Google Tag Manager references found in dist assets!'
echo 'GTM must be properly tree-shaken from OSS builds.'
exit 1
fi
echo '✅ No GTM references found'
- name: Scan dist for Mixpanel telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Mixpanel references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)mixpanel\.init' \
-e '(?i)mixpanel\.identify' \
-e 'MixpanelTelemetryProvider' \
-e 'mp\.comfy\.org' \
-e 'mixpanel-browser' \
-e '(?i)mixpanel\.track\(' \
dist; then
echo '❌ ERROR: Mixpanel references found in dist assets!'
echo 'Mixpanel must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Mixpanel references found'

View File

@@ -21,7 +21,6 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

View File

@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -85,7 +85,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -17,8 +17,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.PR_GH_TOKEN }}
# Setup playwright environment
- name: Setup ComfyUI Frontend

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -109,7 +109,7 @@ jobs:
# Run sharded tests with snapshot updates (browsers pre-installed in container)
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright-tests
run: pnpm exec playwright test --update-snapshots --grep @screenshot --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
continue-on-error: true
- name: Stage changed snapshot files
@@ -173,7 +173,6 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
# Download all changed snapshot files from shards
- name: Download snapshot artifacts

View File

@@ -50,7 +50,6 @@ jobs:
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
pnpm build

2
.gitignore vendored
View File

@@ -96,5 +96,3 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp

View File

@@ -60,6 +60,11 @@
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": ["st", "t", "te", "d"],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}
@@ -96,7 +101,6 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
@@ -106,17 +110,6 @@
"rules": {
"no-console": "allow"
}
},
{
"files": ["browser_tests/**/*.ts"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error"
}
}
]
}

View File

@@ -98,10 +98,12 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true,
strictExecutionOrder: true
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -90,6 +90,7 @@ const preview: Preview = {
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' }
],
showName: true,
dynamicTitle: true
}
}

View File

@@ -52,15 +52,14 @@
"reference",
"plugin",
"custom-variant",
"utility",
"source"
"utility"
]
}
],
"function-no-unknown": [
true,
{
"ignoreFunctions": ["theme", "v-bind", "from-folder", "from-json"]
"ignoreFunctions": ["theme", "v-bind"]
}
]
},

View File

@@ -21,7 +21,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
- i18n: `src/i18n.ts`,
- Entry Point: `src/main.ts`.
- Tests:
- unit/component in `src/**/*.test.ts`
- unit/component in `tests-ui/` and `src/**/*.test.ts`
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
- Public assets: `public/`
- Build output: `dist/`
@@ -44,7 +44,7 @@ The project uses **Nx** for build orchestration and task management
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm typecheck`: Vue TSC type checking
@@ -264,7 +264,7 @@ A particular type of complexity is over-engineering, where developers have made
## Repository Navigation
- Check README files in key folders (browser_tests, composables, etc.)
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
- Prefer running single tests for performance
- Use --help for unfamiliar CLI tools

View File

@@ -2,57 +2,57 @@
* @Comfy-org/comfy_frontend_devs
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Topbar
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
/src/components/topbar/ @pythongosssss
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
/src/renderer/core/thumbnail/ @pythongosssss
# Legacy UI
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs

View File

@@ -201,7 +201,7 @@ The project supports three types of icons, all with automatic imports (no manual
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i class="icon-[lucide--settings]" />`, `<i class="icon-[mdi--folder]" />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Tailwind CSS icon classes (`icon-[comfy--template]`) are provided by `@iconify/tailwind4`, configured in `packages/design-system/src/css/style.css`. Custom icons are stored in `packages/design-system/src/icons/` and loaded via `from-folder` at build time.
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).

View File

@@ -1,8 +1,6 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
import { nextTick, provide } from 'vue'
import type { ElectronWindow } from '@/utils/envUtil'
import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
@@ -44,21 +42,16 @@ const meta: Meta<typeof InstallView> = {
const router = createMockRouter()
// Mock electron API
;(window as ElectronWindow).electronAPI = {
;(window as any).electronAPI = {
getPlatform: () => 'darwin',
Config: {
getDetectedGpu: () => Promise.resolve('mps')
},
Events: {
trackEvent: (
_eventName: string,
_data?: Record<string, unknown>
) => {}
trackEvent: (_eventName: string, _data?: any) => {}
},
installComfyUI: (
_options: Parameters<ElectronAPI['installComfyUI']>[0]
) => {},
changeTheme: (_theme: Parameters<ElectronAPI['changeTheme']>[0]) => {},
installComfyUI: (_options: any) => {},
changeTheme: (_theme: any) => {},
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
@@ -247,8 +240,8 @@ export const DesktopSettings: Story = {
export const WindowsPlatform: Story = {
render: () => {
// Override the platform to Windows
;(window as ElectronWindow).electronAPI.getPlatform = () => 'win32'
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
;(window as any).electronAPI.getPlatform = () => 'win32'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('nvidia')
return {
@@ -266,8 +259,8 @@ export const MacOSPlatform: Story = {
name: 'macOS Platform',
render: () => {
// Override the platform to macOS
;(window as ElectronWindow).electronAPI.getPlatform = () => 'darwin'
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
;(window as any).electronAPI.getPlatform = () => 'darwin'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('mps')
return {
@@ -334,7 +327,7 @@ export const ManualInstall: Story = {
export const ErrorState: Story = {
render: () => {
// Override validation to return an error
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: false,
exists: false,
@@ -382,7 +375,7 @@ export const ErrorState: Story = {
export const WarningState: Story = {
render: () => {
// Override validation to return a warning about non-default drive
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: true,
exists: false,

View File

@@ -4,40 +4,5 @@ See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded fo
## Directory Structure
```text
browser_tests/
├── assets/ - Test data (JSON workflows, images)
├── fixtures/
│ ├── ComfyPage.ts - Main fixture (delegates to helpers)
│ ├── ComfyMouse.ts - Mouse interaction helper
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── components/ - Page object components
│ │ ├── ContextMenu.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ └── Topbar.ts
│ ├── helpers/ - Focused helper classes
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
│ │ ├── KeyboardHelper.ts
│ │ ├── NodeOperationsHelper.ts
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Utility functions
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
- Run `pnpm exec eslint browser_tests/path/to/file.ts` to lint specific files
- Run `pnpm exec oxlint browser_tests/path/to/file.ts` to check with oxlint
## Skill Documentation
A Playwright test-writing skill exists at `.claude/skills/writing-playwright-tests/SKILL.md`.
The skill documents **meta-level guidance only** (gotchas, anti-patterns, decision guides). It does **not** duplicate fixture APIs - agents should read the fixture code directly in `browser_tests/fixtures/`.
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state

View File

@@ -1,205 +0,0 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,186 +0,0 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,599 +0,0 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [210, 108],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 2,
"type": "PreviewAny",
"pos": [1135, 429],
"size": [250, 145.5],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [456, 450],
"size": [225, 121.5],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 6,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1315, 432.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1335, 452.5]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "StringConcatenate",
"pos": [815, 373],
"size": [347, 231],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [955, 775],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [313, 685],
"size": [325, 109],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 12,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [680, 774, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1320, 774, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [780, 794]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1340, 794]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "StringConcatenate",
"pos": [860, 719],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "PrimitiveStringMultiline",
"pos": [401, 973],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1046, 985],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 6,
"origin_slot": 0,
"target_id": 5,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 5,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1330, 1222, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1350, 1242]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "StringConcatenate",
"pos": [870, 1038],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [442, 1296],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-7, 144]
},
"frontendVersion": "1.38.13"
},
"version": 0.4
}

View File

@@ -1,86 +0,0 @@
{
"id": "save-image-and-webm-test",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 100],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1, 2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 11,
"type": "SaveImage",
"pos": [450, 100],
"size": [210, 270],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 12,
"type": "SaveWEBM",
"pos": [450, 450],
"size": [210, 368],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", "vp9", 6, 32]
}
],
"links": [
[1, 10, 0, 11, 0, "IMAGE"],
[2, 10, 0, 12, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 B

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
*/
import type { Locator, Page } from '@playwright/test'
import { TestIds } from './selectors'
import { VueNodeFixture } from './utils/vueNodeFixtures'
export class VueNodeHelpers {
@@ -149,9 +148,9 @@ export class VueNodeHelpers {
* Get a specific widget by node title and widget name
*/
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
return this.getNodeByTitle(nodeTitle).getByLabel(widgetName, {
exact: true
})
return this.getNodeByTitle(nodeTitle).locator(
`_vue=[widget.name="${widgetName}"]`
)
}
/**
@@ -160,8 +159,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
incrementButton: widget.getByTestId(TestIds.widgets.increment)
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
}
}
@@ -171,7 +170,7 @@ export class VueNodeHelpers {
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
const editButton = locator.getByTestId('subgraph-enter-button')
await editButton.click()
}
}

View File

@@ -1,31 +0,0 @@
import type { Locator, Page } from '@playwright/test'
export class BaseDialog {
readonly root: Locator
readonly closeButton: Locator
constructor(
public readonly page: Page,
testId?: string
) {
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
this.closeButton = this.root.getByRole('button', { name: 'Close' })
}
async isVisible(): Promise<boolean> {
return this.root.isVisible()
}
async waitForVisible(): Promise<void> {
await this.root.waitFor({ state: 'visible' })
}
async waitForHidden(): Promise<void> {
await this.root.waitFor({ state: 'hidden' })
}
async close(): Promise<void> {
await this.closeButton.click({ force: true })
await this.waitForHidden()
}
}

View File

@@ -1,35 +0,0 @@
import type { Locator, Page } from '@playwright/test'
class ShortcutsTab {
readonly essentialsTab: Locator
readonly viewControlsTab: Locator
readonly manageButton: Locator
readonly keyBadges: Locator
readonly subcategoryTitles: Locator
constructor(readonly page: Page) {
this.essentialsTab = page.getByRole('tab', { name: /Essential/i })
this.viewControlsTab = page.getByRole('tab', { name: /View Controls/i })
this.manageButton = page.getByRole('button', { name: /Manage Shortcuts/i })
this.keyBadges = page.locator('.key-badge')
this.subcategoryTitles = page.locator('.subcategory-title')
}
}
export class BottomPanel {
readonly root: Locator
readonly keyboardShortcutsButton: Locator
readonly toggleButton: Locator
readonly shortcuts: ShortcutsTab
constructor(readonly page: Page) {
this.root = page.locator('.bottom-panel')
this.keyboardShortcutsButton = page.getByRole('button', {
name: /Keyboard Shortcuts/i
})
this.toggleButton = page.getByRole('button', {
name: /Toggle Bottom Panel/i
})
this.shortcuts = new ShortcutsTab(page)
}
}

View File

@@ -30,7 +30,7 @@ export class ComfyNodeSearchFilterSelectionPanel {
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.getByRole('button', { name: 'Add', exact: true }).click()
await this.page.locator('button:has-text("Add")').click()
}
}
@@ -80,11 +80,4 @@ export class ComfyNodeSearchBox {
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
/**
* Returns a locator for a search result containing the specified text.
*/
findResult(text: string): Locator {
return this.dropdown.locator('li').filter({ hasText: text })
}
}

View File

@@ -1,29 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly results: Locator
readonly filterOptions: Locator
constructor(readonly page: Page) {
this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]')
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
}
categoryButton(categoryId: string): Locator {
return this.dialog.getByTestId(`category-${categoryId}`)
}
filterBarButton(name: string): Locator {
return this.dialog.getByRole('button', { name })
}
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
}
}

View File

@@ -1,54 +0,0 @@
import type { Locator, Page } from '@playwright/test'
export class ContextMenu {
constructor(public readonly page: Page) {}
get primeVueMenu() {
return this.page.locator('.p-contextmenu, .p-menu')
}
get litegraphMenu() {
return this.page.locator('.litemenu')
}
get menuItems() {
return this.page.locator('.p-menuitem, .litemenu-entry')
}
async clickMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
}
async clickLitegraphMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
}
async isVisible(): Promise<boolean> {
const primeVueVisible = await this.primeVueMenu
.isVisible()
.catch(() => false)
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible
}
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([
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
waitIfExists(this.litegraphMenu, 'litegraphMenu')
])
}
}

View File

@@ -1,20 +1,20 @@
import type { Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { BaseDialog } from './BaseDialog'
export class SettingDialog extends BaseDialog {
export class SettingDialog {
constructor(
page: Page,
public readonly page: Page,
public readonly comfyPage: ComfyPage
) {
super(page, TestIds.dialogs.settings)
) {}
get root() {
return this.page.locator('div.settings-container')
}
async open() {
await this.comfyPage.command.executeCommand('Comfy.ShowSettingsDialog')
await this.waitForVisible()
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
await this.page.waitForSelector('div.settings-container')
}
/**
@@ -23,7 +23,9 @@ export class SettingDialog extends BaseDialog {
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').fill(value)
}
@@ -32,31 +34,15 @@ export class SettingDialog extends BaseDialog {
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').click()
}
get searchBox() {
return this.root.getByPlaceholder(/Search/)
}
get categories() {
return this.root.locator('nav').getByRole('button')
}
category(name: string) {
return this.root.locator('nav').getByRole('button', { name })
}
get contentArea() {
return this.root.getByRole('main')
}
async goToAboutPanel() {
const aboutButton = this.root.locator('nav').getByRole('button', {
name: 'About'
})
const aboutButton = this.page.locator('li[aria-label="About"]')
await aboutButton.click()
await this.page.waitForSelector('.about-container')
await this.page.waitForSelector('div.about-container')
}
}

View File

@@ -1,8 +1,5 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { TestIds } from '../selectors'
class SidebarTab {
constructor(
public readonly page: Page,
@@ -34,16 +31,16 @@ class SidebarTab {
}
export class NodeLibrarySidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
constructor(public readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.getByPlaceholder('Search Nodes...')
return this.page.locator('.node-lib-search-box input[type="text"]')
}
get nodeLibraryTree() {
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
return this.page.locator('.node-lib-tree-explorer')
}
get nodePreview() {
@@ -58,12 +55,12 @@ export class NodeLibrarySidebarTab extends SidebarTab {
return this.tabContainer.locator('.new-folder-button')
}
override async open() {
async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
override async close() {
async close() {
if (!this.tabButton.isVisible()) {
return
}
@@ -72,40 +69,30 @@ export class NodeLibrarySidebarTab extends SidebarTab {
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
}
getFolder(folderName: string) {
return this.page.locator(
`[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
)
return this.page.locator(this.folderSelector(folderName))
}
nodeSelector(nodeName: string) {
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
}
getNode(nodeName: string) {
return this.page.locator(
`[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`
)
}
nodeSelector(nodeName: string): string {
return `[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`
}
folderSelector(folderName: string): string {
return `[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
}
getNodeInFolder(nodeName: string, folderName: string) {
return this.getFolder(folderName)
.locator('xpath=ancestor::li')
.locator(`[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`)
return this.page.locator(this.nodeSelector(nodeName))
}
}
export class WorkflowsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get root() {
return this.page.getByTestId(TestIds.sidebar.workflows)
return this.page.locator('.workflows-sidebar-tab')
}
async getOpenedWorkflowNames() {
@@ -153,9 +140,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
() => !window['app']?.extensionManager?.workflow?.isBusy,
undefined,
{ timeout: 3000 }
)

View File

@@ -1,6 +1,5 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { expect } from '@playwright/test'
export class Topbar {
private readonly menuLocator: Locator
@@ -58,7 +57,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
await tab.locator('.close-button').click({ force: true })
}
getSaveDialog(): Locator {
@@ -87,7 +86,7 @@ export class Topbar {
// Wait for workflow service to finish saving
await this.page.waitForFunction(
() => !(window.app!.extensionManager as WorkspaceStore).workflow.isBusy,
() => !window['app'].extensionManager.workflow.isBusy,
undefined,
{ timeout: 3000 }
)
@@ -123,7 +122,7 @@ export class Topbar {
*/
async closeTopbarMenu() {
await this.page.locator('body').click({ position: { x: 300, y: 10 } })
await this.menuLocator.waitFor({ state: 'hidden' })
await expect(this.menuLocator).not.toBeVisible()
}
/**

View File

@@ -1,51 +0,0 @@
import type { Position } from './types'
/**
* Hardcoded positions for the default graph loaded in tests.
* These coordinates are specific to the default workflow viewport.
*/
export const DefaultGraphPositions = {
// Node click positions
textEncodeNode1: { x: 618, y: 191 },
textEncodeNode2: { x: 622, y: 400 },
textEncodeNodeToggler: { x: 430, y: 171 },
emptySpaceClick: { x: 35, y: 31 },
// Slot positions
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
clipTextEncodeNode2InputSlot: { x: 422, y: 402 },
clipTextEncodeNode2InputLinkPath: { x: 395, y: 422 },
loadCheckpointNodeClipOutputSlot: { x: 332, y: 509 },
emptySpace: { x: 427, y: 98 },
// Widget positions
emptyLatentWidgetClick: { x: 724, y: 645 },
// Node positions and sizes for resize operations
ksampler: {
pos: { x: 863, y: 156 },
size: { width: 315, height: 292 }
},
loadCheckpoint: {
pos: { x: 26, y: 444 },
size: { width: 315, height: 127 }
},
emptyLatent: {
pos: { x: 473, y: 579 },
size: { width: 315, height: 136 }
}
} as const satisfies {
textEncodeNode1: Position
textEncodeNode2: Position
textEncodeNodeToggler: Position
emptySpaceClick: Position
clipTextEncodeNode1InputSlot: Position
clipTextEncodeNode2InputSlot: Position
clipTextEncodeNode2InputLinkPath: Position
loadCheckpointNodeClipOutputSlot: Position
emptySpace: Position
emptyLatentWidgetClick: Position
ksampler: { pos: Position; size: { width: number; height: number } }
loadCheckpoint: { pos: Position; size: { width: number; height: number } }
emptyLatent: { pos: Position; size: { width: number; height: number } }
}

View File

@@ -1,9 +0,0 @@
export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -1,184 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position } from '../types'
export class CanvasHelper {
constructor(
private page: Page,
private canvas: Locator,
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
await this.page.mouse.move(10, 10)
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
safeSpot = safeSpot || { x: 10, y: 10 }
await this.page.mouse.move(safeSpot.x, safeSpot.y)
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
safeSpot = safeSpot || { x: 10, y: 10 }
const client = await this.page.context().newCDPSession(this.page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [safeSpot]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: offset.x + safeSpot.x, y: offset.y + safeSpot.y }]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
}
async moveMouseToEmptyArea(): Promise<void> {
await this.page.mouse.move(10, 10)
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale
})
}
async setScale(scale: number): Promise<void> {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await this.nextFrame()
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
return this.page.evaluate((pos) => {
return window.app!.canvas.ds.convertOffsetToCanvas(pos)
}, pos)
}
async getNodeCenterByTitle(title: string): Promise<Position | null> {
return this.page.evaluate((title) => {
const app = window.app!
const node = app.graph.nodes.find(
(n: { title: string }) => n.title === title
)
if (!node) return null
const centerX = node.pos[0] + node.size[0] / 2
const centerY = node.pos[1] + node.size[1] / 2
const [clientX, clientY] = app.canvasPosToClientPos([centerX, centerY])
return { x: clientX, y: clientY }
}, title)
}
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window.app!.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window.app!
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {
const { reverse = false } = options
const start = reverse
? DefaultGraphPositions.clipTextEncodeNode1InputSlot
: DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
const end = reverse
? DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
: DefaultGraphPositions.clipTextEncodeNode1InputSlot
await this.dragAndDrop(start, end)
}
}

View File

@@ -1,15 +0,0 @@
import type { Locator } from '@playwright/test'
import type { KeyboardHelper } from './KeyboardHelper'
export class ClipboardHelper {
constructor(private readonly keyboard: KeyboardHelper) {}
async copy(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyC', locator ?? null)
}
async paste(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
}

View File

@@ -1,84 +0,0 @@
import type { Page } from '@playwright/test'
import type { KeyCombo } from '../../../src/platform/keybindings/types'
export class CommandHelper {
constructor(private readonly page: Page) {}
async executeCommand(
commandId: string,
metadata?: Record<string, unknown>
): Promise<void> {
await this.page.evaluate(
({ commandId, metadata }) => {
return window['app'].extensionManager.command.execute(commandId, {
metadata
})
},
{ commandId, metadata }
)
}
async registerCommand(
commandId: string,
command: (() => void) | (() => Promise<void>)
): Promise<void> {
// SECURITY: eval() is intentionally used here to deserialize/execute functions
// passed from controlled test code across the Node/Playwright browser boundary.
// Execution happens in isolated Playwright browser contexts with test-only data.
// This pattern is unsafe for production and must not be copied elsewhere.
await this.page.evaluate(
({ commandId, commandStr }) => {
const app = window.app!
const randomSuffix = Math.random().toString(36).substring(2, 8)
const extensionName = `TestExtension_${randomSuffix}`
app.registerExtension({
name: extensionName,
commands: [
{
id: commandId,
function: eval(commandStr)
}
]
})
},
{ commandId, commandStr: command.toString() }
)
}
async registerKeybinding(
keyCombo: KeyCombo,
command: () => void
): Promise<void> {
// SECURITY: eval() is intentionally used here to deserialize/execute functions
// passed from controlled test code across the Node/Playwright browser boundary.
// Execution happens in isolated Playwright browser contexts with test-only data.
// This pattern is unsafe for production and must not be copied elsewhere.
await this.page.evaluate(
({ keyCombo, commandStr }) => {
const app = window.app!
const randomSuffix = Math.random().toString(36).substring(2, 8)
const extensionName = `TestExtension_${randomSuffix}`
const commandId = `TestCommand_${randomSuffix}`
app.registerExtension({
name: extensionName,
keybindings: [
{
combo: keyCombo,
commandId: commandId
}
],
commands: [
{
id: commandId,
function: eval(commandStr)
}
]
})
},
{ keyCombo, commandStr: command.toString() }
)
}
}

View File

@@ -1,167 +0,0 @@
import type { Locator, Page, TestInfo } from '@playwright/test'
import type { Position } from '../types'
export interface DebugScreenshotOptions {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
export class DebugHelper {
constructor(
private page: Page,
private canvas: Locator
) {}
async addMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
const existing = document.getElementById(markerId)
if (existing) existing.remove()
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
async removeMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
async attachScreenshot(
testInfo: TestInfo,
name: string,
options?: DebugScreenshotOptions
): Promise<void> {
if (options?.markers) {
for (const marker of options.markers) {
await this.addMarker(marker.position, marker.id)
}
}
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
if (options?.markers) {
await this.removeMarkers()
}
}
async saveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
}
async getCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
async showCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
async hideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
}

View File

@@ -1,161 +0,0 @@
import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '../types'
export class DragDropHelper {
constructor(
private readonly page: Page,
private readonly assetPath: (fileName: string) => string
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
async dragAndDropExternalResource(
options: {
fileName?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
} = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const evaluateParams: {
dropPosition: Position
fileName?: string
fileType?: string
buffer?: Uint8Array | number[]
url?: string
} = { dropPosition }
if (fileName) {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
if (url) evaluateParams.url = url
const uploadResponsePromise = waitForUpload
? this.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10000 }
)
: null
await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer()
if (params.buffer && params.fileName && params.fileType) {
const file = new File(
[new Uint8Array(params.buffer)],
params.fileName,
{
type: params.fileType
}
)
dataTransfer.items.add(file)
}
if (params.url) {
dataTransfer.setData('text/uri-list', params.url)
dataTransfer.setData('text/x-moz-url', params.url)
}
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
throw new Error(
`No element found at drop position: (${params.dropPosition.x}, ${params.dropPosition.y}). ` +
`document.elementFromPoint returned null. Ensure the target is visible and not obscured.`
)
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
}
}
}, evaluateParams)
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropURL(
url: string,
options: { dropPosition?: Position } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}
}

View File

@@ -1,45 +0,0 @@
import type { Locator, Page } from '@playwright/test'
export class KeyboardHelper {
constructor(
private readonly page: Page,
private readonly canvas: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
}
async ctrlSend(
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
}
async selectAll(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyA', locator)
}
async bypass(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyB', locator)
}
async undo(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyZ', locator)
}
async redo(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyY', locator)
}
async moveUp(locator?: Locator | null): Promise<void> {
await this.ctrlSend('ArrowUp', locator)
}
async moveDown(locator?: Locator | null): Promise<void> {
await this.ctrlSend('ArrowDown', locator)
}
}

View File

@@ -1,182 +0,0 @@
import type { Locator } from '@playwright/test'
import type {
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position, Size } from '../types'
import { NodeReference } from '../utils/litegraphUtils'
export class NodeOperationsHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {
return this.comfyPage.page
}
async getGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return window.app?.graph?.nodes?.length || 0
})
}
async getSelectedGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return (
window.app?.graph?.nodes?.filter(
(node: LGraphNode) => node.is_selected === true
).length || 0
)
})
}
async getNodes(): Promise<LGraphNode[]> {
return await this.page.evaluate(() => {
return window.app!.graph.nodes
})
}
async waitForGraphNodes(count: number): Promise<void> {
await this.page.waitForFunction((count) => {
return window.app?.canvas.graph?.nodes?.length === count
}, count)
}
async getFirstNodeRef(): Promise<NodeReference | null> {
const id = await this.page.evaluate(() => {
return window.app!.graph.nodes[0]?.id
})
if (!id) return null
return this.getNodeRefById(id)
}
async getNodeRefById(id: NodeId): Promise<NodeReference> {
return new NodeReference(id, this.comfyPage)
}
async getNodeRefsByType(
type: string,
includeSubgraph: boolean = false
): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate(
({ type, includeSubgraph }) => {
const graph = (
includeSubgraph ? window.app!.canvas.graph : window.app!.graph
) as LGraph
const nodes = graph.nodes
return nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
},
{ type, includeSubgraph }
)
).map((id: NodeId) => this.getNodeRefById(id))
)
}
async getNodeRefsByTitle(title: string): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate((title) => {
return window
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
.map((n: LGraphNode) => n.id)
}, title)
).map((id: NodeId) => this.getNodeRefById(id))
)
}
async selectNodes(nodeTitles: string[]): Promise<void> {
await this.page.keyboard.down('Control')
try {
for (const nodeTitle of nodeTitles) {
const nodes = await this.getNodeRefsByTitle(nodeTitle)
for (const node of nodes) {
await node.click('title')
}
}
} finally {
await this.page.keyboard.up('Control')
await this.comfyPage.nextFrame()
}
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
ratioX: number,
ratioY: number,
revertAfter: boolean = false
): Promise<void> {
const bottomRight = {
x: nodePos.x + nodeSize.width,
y: nodePos.y + nodeSize.height
}
const target = {
x: nodePos.x + nodeSize.width * ratioX,
y: nodePos.y + nodeSize.height * ratioY
}
// -1 to be inside the node. -2 because nodes currently get an arbitrary +1 to width.
await this.comfyPage.canvasOps.dragAndDrop(
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.comfyPage.nextFrame()
if (revertAfter) {
await this.comfyPage.canvasOps.dragAndDrop(
{ x: target.x - 2, y: target.y - 1 },
bottomRight
)
await this.comfyPage.nextFrame()
}
}
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
await this.comfyPage.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
if (!node) {
throw new Error('No nodes found to convert')
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
}
get promptDialogInput(): Locator {
return this.page.locator('.p-dialog-content input[type="text"]')
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')
await this.promptDialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
async dragTextEncodeNode2(): Promise<void> {
await this.comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.textEncodeNode2,
{
x: DefaultGraphPositions.textEncodeNode2.x,
y: 300
}
)
await this.comfyPage.nextFrame()
}
async adjustEmptyLatentWidth(): Promise<void> {
await this.page.locator('#graph-canvas').click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
const dialogInput = this.page.locator('.graphdialog input[type="text"]')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,20 +0,0 @@
import type { Page } from '@playwright/test'
export class SettingsHelper {
constructor(private readonly page: Page) {}
async setSetting(settingId: string, settingValue: unknown): Promise<void> {
await this.page.evaluate(
async ({ id, value }) => {
await window.app!.extensionManager.setting.set(id, value)
},
{ id: settingId, value: settingValue }
)
}
async getSetting<T = unknown>(settingId: string): Promise<T> {
return (await this.page.evaluate(async (id) => {
return await window.app!.extensionManager.setting.get(id)
}, settingId)) as T
}
}

View File

@@ -1,325 +0,0 @@
import type { Page } from '@playwright/test'
import type {
CanvasPointerEvent,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
export class SubgraphHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/**
* Core helper method for interacting with subgraph I/O slots.
* Handles both input/output slots and both right-click/double-click actions.
*
* @param slotType - 'input' or 'output'
* @param action - 'rightClick' or 'doubleClick'
* @param slotName - Optional specific slot name to target
*/
private async interactWithSubgraphSlot(
slotType: 'input' | 'output',
action: 'rightClick' | 'doubleClick',
slotName?: string
): Promise<void> {
const foundSlot = await this.page.evaluate(
async (params) => {
const { slotType, action, targetSlotName } = params
const app = window.app!
const currentGraph = app.canvas!.graph!
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const subgraph = currentGraph as Subgraph
// Get the appropriate node and slots
const node =
slotType === 'input' ? subgraph.inputNode : subgraph.outputNode
const slots = slotType === 'input' ? subgraph.inputs : subgraph.outputs
if (!node) {
throw new Error(`No ${slotType} node found in subgraph`)
}
if (!slots || slots.length === 0) {
throw new Error(`No ${slotType} slots found in subgraph`)
}
// Filter slots based on target name and action type
const slotsToTry = targetSlotName
? slots.filter((slot) => slot.name === targetSlotName)
: action === 'rightClick'
? slots
: [slots[0]] // Right-click tries all, double-click uses first
if (slotsToTry.length === 0) {
throw new Error(
targetSlotName
? `${slotType} slot '${targetSlotName}' not found`
: `No ${slotType} slots available to try`
)
}
// Handle the interaction based on action type
if (action === 'rightClick') {
// Right-click: try each slot until one works
for (const slot of slotsToTry) {
if (!slot.pos) continue
const event = {
canvasX: slot.pos[0],
canvasY: slot.pos[1],
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
return {
success: true,
slotName: slot.name,
x: slot.pos[0],
y: slot.pos[1]
}
}
}
} else if (action === 'doubleClick') {
// Double-click: use first slot with bounding rect center
const slot = slotsToTry[0]
if (!slot.boundingRect) {
throw new Error(`${slotType} slot bounding rect not found`)
}
const rect = slot.boundingRect
const testX = rect[0] + rect[2] / 2 // x + width/2
const testY = rect[1] + rect[3] / 2 // y + height/2
const event = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(
event as unknown as CanvasPointerEvent
)
}
}
return { success: true, slotName: slot.name, x: testX, y: testY }
}
return { success: false }
},
{ slotType, action, targetSlotName: slotName }
)
if (!foundSlot.success) {
const actionText =
action === 'rightClick' ? 'open context menu for' : 'double-click'
throw new Error(
slotName
? `Could not ${actionText} ${slotType} slot '${slotName}'`
: `Could not find any ${slotType} slot to ${actionText}`
)
}
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
} else {
await this.comfyPage.nextFrame()
}
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
*
* This method uses the actual slot positions from the subgraph.inputs array,
* which contain the correct coordinates for each input slot. These positions
* are different from the visual node positions and are specifically where
* the slots are rendered on the input node.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries all available input slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'rightClick', inputName)
}
/**
* Right-clicks on a subgraph output slot to open the context menu.
* Must be called when inside a subgraph.
*
* Similar to rightClickInputSlot but for output slots.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries all available output slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'rightClick', outputName)
}
/**
* Double-clicks on a subgraph input slot to rename it.
* Must be called when inside a subgraph.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries the first available input slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'doubleClick', inputName)
}
/**
* Double-clicks on a subgraph output slot to rename it.
* Must be called when inside a subgraph.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries the first available output slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'doubleClick', outputName)
}
/**
* Get a reference to a subgraph input slot
*/
getInputSlot(slotName?: string): SubgraphSlotReference {
return new SubgraphSlotReference('input', slotName || '', this.comfyPage)
}
/**
* Get a reference to a subgraph output slot
*/
getOutputSlot(slotName?: string): SubgraphSlotReference {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
*/
async connectToInput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetInputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = this.getInputSlot(targetInputName)
const targetPosition = targetInputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
await sourceSlot.getPosition(),
targetPosition
)
await this.comfyPage.nextFrame()
}
/**
* Connect a subgraph input to a regular node input.
* This creates a new input slot on the subgraph if sourceInputName is not provided.
*/
async connectFromInput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceInputName?: string
): Promise<void> {
const sourceSlot = this.getInputSlot(sourceInputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceInputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
const targetPosition = await targetSlot.getPosition()
await this.comfyPage.canvasOps.dragAndDrop(sourcePosition, targetPosition)
await this.comfyPage.nextFrame()
}
/**
* Connect a regular node output to a subgraph output.
* This creates a new output slot on the subgraph if targetOutputName is not provided.
*/
async connectToOutput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetOutputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = this.getOutputSlot(targetOutputName)
const targetPosition = targetOutputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
await sourceSlot.getPosition(),
targetPosition
)
await this.comfyPage.nextFrame()
}
/**
* Connect a subgraph output to a regular node input.
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
*/
async connectFromOutput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceOutputName?: string
): Promise<void> {
const sourceSlot = this.getOutputSlot(sourceOutputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceOutputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
sourcePosition,
await targetSlot.getPosition()
)
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,39 +0,0 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ToastHelper {
constructor(private readonly page: Page) {}
get visibleToasts(): Locator {
return this.page.locator('.p-toast-message:visible')
}
async getToastErrorCount(): Promise<number> {
return await this.page
.locator('.p-toast-message.p-toast-message-error')
.count()
}
async getVisibleToastCount(): Promise<number> {
return await this.visibleToasts.count()
}
async closeToasts(requireCount = 0): Promise<void> {
if (requireCount) {
await this.visibleToasts
.nth(requireCount - 1)
.waitFor({ state: 'visible' })
}
// Clear all toasts
const toastCloseButtons = await this.page
.locator('.p-toast-close-button')
.all()
for (const button of toastCloseButtons) {
await button.click()
}
// Assert all toasts are closed
await expect(this.visibleToasts).toHaveCount(0, { timeout: 1000 })
}
}

View File

@@ -1,126 +0,0 @@
import { readFileSync } from 'fs'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { WorkspaceStore } from '../../types/globals'
import type { ComfyPage } from '../ComfyPage'
type FolderStructure = {
[key: string]: FolderStructure | string
}
export class WorkflowHelper {
constructor(private readonly comfyPage: ComfyPage) {}
convertLeafToContent(structure: FolderStructure): FolderStructure {
const result: FolderStructure = {}
for (const [key, value] of Object.entries(structure)) {
if (typeof value === 'string') {
const filePath = this.comfyPage.assetPath(value)
result[key] = readFileSync(filePath, 'utf-8')
} else {
result[key] = this.convertLeafToContent(value)
}
}
return result
}
async setupWorkflowsDirectory(structure: FolderStructure) {
const resp = await this.comfyPage.request.post(
`${this.comfyPage.url}/api/devtools/setup_folder_structure`,
{
data: {
tree_structure: this.convertLeafToContent(structure),
base_path: `user/${this.comfyPage.id}/workflows`
}
}
)
if (resp.status() !== 200) {
throw new Error(
`Failed to setup workflows directory: ${await resp.text()}`
)
}
await this.comfyPage.page.evaluate(async () => {
await (
window.app!.extensionManager as WorkspaceStore
).workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.comfyPage.nextFrame()
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
this.comfyPage.assetPath(`${workflowName}.json`)
)
await this.comfyPage.nextFrame()
}
async deleteWorkflow(
workflowName: string,
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
) {
// Open workflows tab
const { workflowsTab } = this.comfyPage.menu
await workflowsTab.open()
// Action to take if workflow missing
if (whenMissing === 'ignoreMissing') {
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
if (!workflows.includes(workflowName)) return
}
// Delete workflow
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
await this.comfyPage.contextMenu.clickMenuItem('Delete')
await this.comfyPage.nextFrame()
await this.comfyPage.confirmDialog.delete.click()
// Clear toast & close tab
await this.comfyPage.toast.closeToasts(1)
await workflowsTab.close()
}
async getUndoQueueSize(): Promise<number | undefined> {
return this.comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
async getRedoQueueSize(): Promise<number | undefined> {
return this.comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
async getExportedWorkflow(options?: {
api?: false
}): Promise<ComfyWorkflowJSON>
async getExportedWorkflow(options?: {
api?: boolean
}): Promise<ComfyWorkflowJSON | ComfyApiWorkflow> {
const api = options?.api ?? false
return this.comfyPage.page.evaluate(async (api) => {
return (await window.app!.graphToPrompt())[api ? 'output' : 'workflow']
}, api)
}
}

View File

@@ -1,77 +0,0 @@
/**
* Centralized test selectors for browser tests.
* Use data-testid attributes for stable selectors.
*/
export const TestIds = {
sidebar: {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
tree: {
folder: 'tree-folder',
leaf: 'tree-leaf',
node: 'tree-node'
},
canvas: {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
},
dialogs: {
settings: 'settings-dialog',
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
},
topbar: {
queueButton: 'queue-button',
saveButton: 'save-workflow-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel'
},
node: {
titleInput: 'node-title-input'
},
widgets: {
decrement: 'decrement',
increment: 'increment',
subgraphEnterButton: 'subgraph-enter-button'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
},
user: {
currentUserIndicator: 'current-user-indicator'
}
} as const
/**
* Helper type for accessing nested TestIds (excludes function values)
*/
export type TestIdValue =
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]

View File

@@ -1,4 +1,3 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
@@ -23,10 +22,10 @@ export class SubgraphSlotReference {
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type, slotName]) => {
const currentGraph = window.app!.canvas.graph!
const currentGraph = window['app'].canvas.graph
// Check if we're in a subgraph (subgraphs have inputNode property)
if (!('inputNode' in currentGraph)) {
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
@@ -52,7 +51,7 @@ export class SubgraphSlotReference {
}
// Convert from offset to canvas coordinates
const canvasPos = window.app!.canvas.ds.convertOffsetToCanvas([
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slot.pos[0],
slot.pos[1]
])
@@ -70,10 +69,9 @@ export class SubgraphSlotReference {
async getOpenSlotPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type]) => {
const currentGraph = window.app!.canvas.graph!
const currentGraph = window['app'].canvas.graph
// Check if we're in a subgraph (subgraphs have inputNode property)
if (!('inputNode' in currentGraph)) {
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
@@ -87,7 +85,7 @@ export class SubgraphSlotReference {
}
// Convert from offset to canvas coordinates
const canvasPos = window.app!.canvas.ds.convertOffsetToCanvas([
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
node.emptySlot.pos[0],
node.emptySlot.pos[1]
])
@@ -113,12 +111,12 @@ class NodeSlotReference {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
const node = window.app!.canvas.graph!.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const rawPos = node.getConnectionPos(type === 'input', index)
const convertedPos =
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility
console.warn(
@@ -128,7 +126,7 @@ class NodeSlotReference {
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window.app!.canvas.graph!.constructor.name
currentGraphType: window['app'].canvas.graph.constructor.name
}
)
@@ -144,7 +142,7 @@ class NodeSlotReference {
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
@@ -157,7 +155,7 @@ class NodeSlotReference {
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
@@ -182,15 +180,15 @@ class NodeWidgetReference {
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets![index]
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const [x, y, w, _h] = node.getBounding()
return window.app!.canvasPosToClientPos([
const [x, y, w, h] = node.getBounding()
return window['app'].canvasPosToClientPos([
x + w / 2,
y + window.LiteGraph!['NODE_TITLE_HEIGHT'] + widget.last_y! + 1
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
])
},
[this.node.id, this.index] as const
@@ -207,9 +205,9 @@ class NodeWidgetReference {
async getSocketPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window.app!.graph!.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets![index]
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
const slot = node.inputs.find(
@@ -218,9 +216,9 @@ class NodeWidgetReference {
if (!slot) throw new Error(`Socket ${widget.name} not found.`)
const [x, y] = node.getBounding()
return window.app!.canvasPosToClientPos([
x + slot.pos![0],
y + slot.pos![1] + window.LiteGraph!['NODE_TITLE_HEIGHT']
return window['app'].canvasPosToClientPos([
x + slot.pos[0],
y + slot.pos[1] + window['LiteGraph']['NODE_TITLE_HEIGHT']
])
},
[this.node.id, this.index] as const
@@ -241,7 +239,7 @@ class NodeWidgetReference {
const pos = await this.getPosition()
const canvas = this.node.comfyPage.canvas
const canvasPos = (await canvas.boundingBox())!
await this.node.comfyPage.canvasOps.dragAndDrop(
await this.node.comfyPage.dragAndDrop(
{
x: canvasPos.x + pos.x,
y: canvasPos.y + pos.y
@@ -256,9 +254,9 @@ class NodeWidgetReference {
async getValue() {
return await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window.app!.graph!.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets![index]
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
return widget.value
},
@@ -273,7 +271,7 @@ export class NodeReference {
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
return !!node
}, this.id)
}
@@ -281,7 +279,7 @@ export class NodeReference {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
@@ -290,11 +288,12 @@ export class NodeReference {
}
}
async getBounding(): Promise<Position & Size> {
const [x, y, width, height] = await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
return [...node.getBounding()] as [number, number, number, number]
}, this.id)
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
return {
x,
y,
@@ -312,11 +311,6 @@ export class NodeReference {
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
return await this.getProperty('flags')
}
async getTitlePosition(): Promise<Position> {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
}
async isPinned() {
return !!(await this.getFlags()).pinned
}
@@ -329,9 +323,9 @@ export class NodeReference {
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return (node as unknown as Record<string, T>)[prop]
return node[prop]
},
[this.id, prop] as const
)
@@ -349,16 +343,16 @@ export class NodeReference {
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = await this.getTitlePosition()
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
break
case 'collapse': {
const nodePos = await this.getPosition()
case 'collapse':
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
break
}
default:
throw new Error(`Invalid click position ${position}`)
}
@@ -375,12 +369,12 @@ export class NodeReference {
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
await this.comfyPage.moveMouseToEmptyArea()
}
}
async copy() {
await this.click('title')
await this.comfyPage.clipboard.copy()
await this.comfyPage.ctrlC()
await this.comfyPage.nextFrame()
}
async connectWidget(
@@ -390,7 +384,7 @@ export class NodeReference {
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
await this.comfyPage.canvasOps.dragAndDrop(
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetWidget.getSocketPosition()
)
@@ -403,7 +397,7 @@ export class NodeReference {
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.canvasOps.dragAndDrop(
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
@@ -421,9 +415,9 @@ export class NodeReference {
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
await this.comfyPage.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
@@ -434,8 +428,7 @@ export class NodeReference {
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()
const nodes =
await this.comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
if (nodes.length !== 1) {
throw new Error(
`Did not find single subgraph node (found=${nodes.length})`
@@ -453,7 +446,7 @@ export class NodeReference {
}
async navigateIntoSubgraph() {
const titleHeight = await this.comfyPage.page.evaluate(() => {
return window.LiteGraph!['NODE_TITLE_HEIGHT']
return window['LiteGraph']['NODE_TITLE_HEIGHT']
})
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
@@ -465,14 +458,13 @@ export class NodeReference {
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
const checkIsInSubgraph = async () => {
return this.comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
}
let isInSubgraph = false
let attempts = 0
const maxAttempts = 3
while (!isInSubgraph && attempts < maxAttempts) {
attempts++
await expect(async () => {
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
@@ -485,9 +477,24 @@ export class NodeReference {
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
if (await checkIsInSubgraph()) return
// Check if we successfully entered the subgraph
isInSubgraph = await this.comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
if (isInSubgraph) break
}
throw new Error('Not in subgraph yet')
}).toPass({ timeout: 5000, intervals: [100, 200, 500] })
if (!isInSubgraph && attempts < maxAttempts) {
await this.comfyPage.page.waitForTimeout(500)
}
}
if (!isInSubgraph) {
throw new Error(
'Failed to navigate into subgraph after ' + attempts + ' attempts'
)
}
}
}

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
@@ -39,7 +40,7 @@ export class VueNodeFixture {
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await expect(input).toBeVisible()
await input.fill(value)
await input.press('Enter')
}
@@ -47,7 +48,7 @@ export class VueNodeFixture {
async cancelTitleEdit(): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await input.waitFor({ state: 'visible' })
await expect(input).toBeVisible()
await input.press('Escape')
}

View File

@@ -1,11 +1,7 @@
import { test as base } from '@playwright/test'
interface TestWindow extends Window {
__ws__?: Record<string, WebSocket>
}
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
ws: { trigger(data: any, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {
@@ -14,7 +10,7 @@ export const webSocketFixture = base.extend<{
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = (window.__ws__ = {})
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
@@ -38,7 +34,7 @@ export const webSocketFixture = base.extend<{
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = window.__ws__![url]
const ws: WebSocket = (window as any).__ws__[url]
ws.dispatchEvent(
new MessageEvent('message', {
data

View File

@@ -5,7 +5,7 @@ import { backupPath } from './utils/backupUtils'
dotenv.config()
export default function globalSetup(_config: FullConfig) {
export default function globalSetup(config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])

View File

@@ -5,7 +5,7 @@ import { restorePath } from './utils/backupUtils'
dotenv.config()
export default function globalTeardown(_config: FullConfig) {
export default function globalTeardown(config: FullConfig) {
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])

View File

@@ -1,8 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore'
import { TestIds } from '../fixtures/selectors'
import type { WorkspaceStore } from '../types/globals'
export class ComfyActionbar {
public readonly root: Locator
@@ -28,7 +26,7 @@ class ComfyQueueButton {
public readonly primaryButton: Locator
public readonly dropdownButton: Locator
constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
this.root = actionbar.root.getByTestId('queue-button')
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
@@ -44,14 +42,13 @@ class ComfyQueueButtonOptions {
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {
;(window.app!.extensionManager as WorkspaceStore).queueSettings.mode =
mode
window['app'].extensionManager.queueSettings.mode = mode
}, mode)
}
public async getMode() {
return await this.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).queueSettings.mode
return window['app'].extensionManager.queueSettings.mode
})
}
}

View File

@@ -23,7 +23,7 @@ export async function fitToViewInstant(
{ selectionOnly: boolean }
>(
({ selectionOnly }) => {
const app = window.app
const app = window['app']
if (!app?.canvas) return null
const canvas = app.canvas
@@ -90,7 +90,7 @@ export async function fitToViewInstant(
await comfyPage.page.evaluate(
({ bounds, zoom }) => {
const app = window.app
const app = window['app']
if (!app?.canvas) return
const canvas = app.canvas

View File

@@ -1,16 +0,0 @@
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
/**
* Assertion helper for tests where being in a subgraph is a precondition.
* Throws a clear error if the graph is not a Subgraph.
*/
export function assertSubgraph(
graph: LGraph | Subgraph | null | undefined
): asserts graph is Subgraph {
if (!isSubgraph(graph)) {
throw new Error(
'Expected to be in a subgraph context, but graph is not a Subgraph'
)
}
}

View File

@@ -6,19 +6,18 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '../../src/platform/workflow/templates/types/template'
import { TestIds } from '../fixtures/selectors'
export class ComfyTemplates {
readonly content: Locator
readonly allTemplateCards: Locator
constructor(readonly page: Page) {
this.content = page.getByTestId(TestIds.templates.content)
this.content = page.getByTestId('template-workflows-content')
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
}
async expectMinimumCardCount(count: number) {
await expect(async () => {
async waitForMinimumCardCount(count: number) {
return await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
@@ -27,16 +26,14 @@ export class ComfyTemplates {
}
async loadTemplate(id: string) {
const templateCard = this.content.getByTestId(
TestIds.templates.workflowCard(id)
)
const templateCard = this.content.getByTestId(`template-workflow-${id}`)
await templateCard.scrollIntoViewIfNeeded()
await templateCard.getByRole('img').click()
}
async getAllTemplates(): Promise<TemplateInfo[]> {
const templates: WorkflowTemplates[] = await this.page.evaluate(() =>
window.app!.api.getCoreWorkflowTemplates()
window['app'].api.getCoreWorkflowTemplates()
)
return templates.flatMap((t) => t.templates)
}

View File

@@ -1,16 +1,15 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WorkspaceStore } from '../types/globals'
import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage.ts'
import { webSocketFixture } from '../fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Actionbar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
/**
@@ -50,14 +49,13 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
// Find and set the width on the latent node
const triggerChange = async (value: number) => {
return await comfyPage.page.evaluate((value) => {
const node = window.app!.graph!._nodes.find(
const node = window['app'].graph._nodes.find(
(n) => n.type === 'EmptyLatentImage'
)
node!.widgets![0].value = value
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.checkState()
node.widgets[0].value = value
window[
'app'
].extensionManager.workflow.activeWorkflow.changeTracker.checkState()
}, value)
}

View File

@@ -3,18 +3,18 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test.afterEach(async ({ comfyPage }) => {
// Clean up background image setting after each test
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test('should show background image upload component in settings', async ({
@@ -34,18 +34,16 @@ test.describe('Background Image Upload', () => {
await expect(backgroundImageSetting).toBeVisible()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.getByRole('textbox')
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
@@ -65,9 +63,9 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
@@ -78,17 +76,15 @@ test.describe('Background Image Upload', () => {
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.getByRole('textbox')
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.settings.getSetting(
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
@@ -111,20 +107,18 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.getByRole('textbox')
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.settings.getSetting(
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
@@ -136,10 +130,7 @@ test.describe('Background Image Upload', () => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
testImageUrl
)
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', testImageUrl)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
@@ -153,13 +144,11 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.getByRole('textbox')
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Click the clear button
@@ -172,7 +161,7 @@ test.describe('Background Image Upload', () => {
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.settings.getSetting(
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
@@ -193,9 +182,9 @@ test.describe('Background Image Upload', () => {
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await uploadButton.hover()
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
@@ -205,14 +194,12 @@ test.describe('Background Image Upload', () => {
await comfyPage.page.locator('body').hover()
// Set a background to enable clear button
const urlInput = backgroundImageSetting.getByRole('textbox')
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await clearButton.hover()
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
@@ -233,10 +220,8 @@ test.describe('Background Image Upload', () => {
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.getByRole('textbox')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()

View File

@@ -4,33 +4,53 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
await expect(bottomPanel.root).not.toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible()
// Click shortcuts toggle button in sidebar
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Click toggle button again to hide
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Panel should be hidden again
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
})
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
// Essentials tab should be visible and active by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toBeVisible()
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.shortcuts.essentialsTab).toBeVisible()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
// Should display shortcut categories
await expect(
comfyPage.page.locator('.subcategory-title').first()
).toBeVisible()
await expect(bottomPanel.shortcuts.subcategoryTitles.first()).toBeVisible()
await expect(bottomPanel.shortcuts.keyBadges.first()).toBeVisible()
// Should display some keyboard shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
// Should have workflow, node, and queue sections
await expect(
comfyPage.page.getByRole('heading', { name: 'Workflow' })
).toBeVisible()
@@ -43,18 +63,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
})
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.viewControlsTab.click()
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
await expect(bottomPanel.shortcuts.viewControlsTab).toHaveAttribute(
'aria-selected',
'true'
)
// View controls tab should be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.shortcuts.keyBadges.first()).toBeVisible()
// Should display view controls shortcuts
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
// Should have view and panel controls sections
await expect(
comfyPage.page.getByRole('heading', { name: 'View' })
).toBeVisible()
@@ -64,48 +89,54 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
})
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
// Essentials should be active initially
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
// Click view controls tab
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
await bottomPanel.shortcuts.viewControlsTab.click()
// View controls should now be active
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).not.toHaveAttribute('aria-selected', 'true')
await expect(bottomPanel.shortcuts.viewControlsTab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(bottomPanel.shortcuts.essentialsTab).not.toHaveAttribute(
'aria-selected',
'true'
)
// Switch back to essentials
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
await bottomPanel.shortcuts.essentialsTab.click()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(bottomPanel.shortcuts.viewControlsTab).not.toHaveAttribute(
'aria-selected',
'true'
)
// Essentials should be active again
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).not.toHaveAttribute('aria-selected', 'true')
})
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
// Wait for shortcuts to load
await comfyPage.page.waitForSelector('.key-badge')
const keyBadges = bottomPanel.shortcuts.keyBadges
await keyBadges.first().waitFor({ state: 'visible' })
// Check for common formatted keys
const keyBadges = comfyPage.page.locator('.key-badge')
const count = await keyBadges.count()
expect(count).toBeGreaterThanOrEqual(1)
// Should show formatted modifier keys
const badgeText = await keyBadges.allTextContents()
const hasModifiers = badgeText.some((text) =>
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
@@ -113,89 +144,91 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
expect(hasModifiers).toBeTruthy()
})
test('should maintain panel state when switching between panels', async ({
test('should maintain panel state when switching to terminal', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel first
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Open terminal panel (should switch panels)
await comfyPage.page
.locator('button[aria-label*="Toggle Bottom Panel"]')
.click()
// Panel should still be visible but showing terminal content
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Switch back to shortcuts
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
// Should show shortcuts content again
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
// Try to open terminal panel - may show terminal OR close shortcuts
// depending on whether terminal tabs have loaded (async loading)
await bottomPanel.toggleButton.click()
// Check if terminal tabs loaded (Logs tab visible) or fell back to shortcuts toggle
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
const hasTerminalTabs = await logsTab.isVisible().catch(() => false)
if (hasTerminalTabs) {
// Terminal panel is visible - verify we can switch back to shortcuts
await expect(bottomPanel.root).toBeVisible()
// Switch back to shortcuts
await bottomPanel.keyboardShortcutsButton.click()
// Should show shortcuts content again
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
} else {
// Terminal tabs not loaded - button toggled shortcuts off, reopen for verification
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
}
})
test('should handle keyboard navigation', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.essentialsTab.focus()
// Focus the first tab
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
// Use arrow keys to navigate between tabs
await comfyPage.page.keyboard.press('ArrowRight')
await expect(bottomPanel.shortcuts.viewControlsTab).toBeFocused()
// View controls tab should now have focus
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toBeFocused()
// Press Enter to activate the tab
await comfyPage.page.keyboard.press('Enter')
await expect(bottomPanel.shortcuts.viewControlsTab).toHaveAttribute(
'aria-selected',
'true'
)
// Tab should be selected
await expect(
comfyPage.page.getByRole('tab', { name: /View Controls/i })
).toHaveAttribute('aria-selected', 'true')
})
test('should close panel by clicking shortcuts button again', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
// Click shortcuts button again to close
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible()
// Panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
})
test('should display shortcuts in organized columns', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
// Should have 3-column grid layout
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
).toBeVisible()
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
// Should have multiple subcategory sections
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
const titleCount = await subcategoryTitles.count()
expect(titleCount).toBeGreaterThanOrEqual(2)
})
@@ -203,30 +236,45 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test('should open shortcuts panel with Ctrl+Shift+K', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
// Initially shortcuts panel should be hidden
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
// Press Ctrl+Shift+K to open shortcuts panel
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
await expect(bottomPanel.root).toBeVisible()
await expect(bottomPanel.shortcuts.essentialsTab).toHaveAttribute(
'aria-selected',
'true'
)
// Shortcuts panel should now be visible
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
// Should show essentials tab by default
await expect(
comfyPage.page.getByRole('tab', { name: /Essential/i })
).toHaveAttribute('aria-selected', 'true')
})
test('should open settings dialog when clicking manage shortcuts button', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
// Open shortcuts panel
await comfyPage.page
.locator('button[aria-label*="Keyboard Shortcuts"]')
.click()
await bottomPanel.keyboardShortcutsButton.click()
// Manage shortcuts button should be visible
await expect(
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
).toBeVisible()
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
await bottomPanel.shortcuts.manageButton.click()
// Click manage shortcuts button
await comfyPage.page
.getByRole('button', { name: /Manage Shortcuts/i })
.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
// Settings dialog should open with keybinding tab
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
// Should show keybinding settings (check for keybinding-related content)
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
})
})

View File

@@ -1,18 +1,16 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Browser tab title', { tag: '@smoke' }, () => {
test.describe('Beta Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can display workflow name', async ({ comfyPage }) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
return window['app'].extensionManager.workflow.activeWorkflow.filename
})
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
})
@@ -23,8 +21,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
comfyPage
}) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
return window['app'].extensionManager.workflow.activeWorkflow.filename
})
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
@@ -33,21 +30,19 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {
return (
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.delete()
return window['app'].extensionManager.workflow.activeWorkflow.delete()
})
})
})
test.describe('Legacy Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display default title', async ({ comfyPage }) => {

View File

@@ -6,69 +6,69 @@ import {
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitBeforeChange()
window['app'].canvas.emitBeforeChange()
})
}
async function afterChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitAfterChange()
window['app'].canvas.emitAfterChange()
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', { tag: '@workflow' }, () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setupWorkflowsDirectory({})
})
test('Can undo multiple operations', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const node = (await comfyPage.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.bypass()
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(2)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(2)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(1)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(1)
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(2)
})
})
test('Can group multiple change actions into a single transaction', async ({
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const node = (await comfyPage.getFirstNodeRef())!
expect(node).toBeTruthy()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed()
@@ -77,27 +77,27 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
// Bypass + collapse node
await node.click('title')
await node.click('collapse')
await comfyPage.keyboard.bypass()
await comfyPage.ctrlB()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// Undo, undo, ensure both changes undone
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).toBeCollapsed()
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
// Prevent clicks registering a double-click
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.clickEmptySpace()
await node.click('title')
// Run again, but within a change transaction
await beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.keyboard.bypass()
await comfyPage.ctrlB()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
@@ -105,7 +105,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await afterChange(comfyPage)
// Ensure undo reverts both changes
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
})
@@ -113,10 +113,10 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
test('Can nest multiple change transactions without adding undo steps', async ({
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const node = (await comfyPage.getFirstNodeRef())!
const bypassAndPin = async () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
@@ -142,30 +142,30 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await multipleChanges()
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBePinned()
await expect(node).not.toBeCollapsed()
await comfyPage.keyboard.redo()
await comfyPage.ctrlY()
await expect(node).toBeBypassed()
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.graph!.extra.foo = 'bar'
window['app'].graph.extra.foo = 'bar'
})
// Click empty space to trigger a change detection.
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
await comfyPage.clickEmptySpace()
expect(await comfyPage.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.pan({ x: 10, y: 10 })
expect(await comfyPage.getUndoQueueSize()).toBe(0)
})
})

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import type { Palette } from '../../src/schemas/colorPaletteSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const customColorPalettes = {
const customColorPalettes: Record<string, Palette> = {
obsidian: {
version: 102,
id: 'obsidian',
@@ -153,48 +153,40 @@ const customColorPalettes = {
test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.CustomColorPalettes',
customColorPalettes
)
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
// Reload to apply the new setting. Setting Comfy.CustomColorPalettes directly
// doesn't update the store immediately.
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.loadWorkflow('nodes/every_node_color')
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-light-red.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
test('Can add custom color palette', async ({ comfyPage }) => {
await comfyPage.page.evaluate(async (p) => {
await (
window.app!.extensionManager as WorkspaceStore
).colorPalette.addCustomColorPalette(p)
await comfyPage.page.evaluate((p) => {
window['app'].extensionManager.colorPalette.addCustomColorPalette(p)
}, customColorPalettes.obsidian_dark)
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
expect(await comfyPage.getToastErrorCount()).toBe(0)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
// Legacy `custom_` prefix is still supported
await comfyPage.settings.setSetting(
'Comfy.ColorPalette',
'custom_obsidian_dark'
)
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
@@ -207,20 +199,20 @@ test.describe(
{ tag: ['@screenshot', '@settings'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.loadWorkflow('nodes/every_node_color')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
@@ -229,8 +221,8 @@ test.describe(
test('should persist color adjustments when changing themes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -241,12 +233,24 @@ test.describe(
test('should not serialize color adjustments in workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
})
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
@@ -258,7 +262,7 @@ test.describe(
test('should lighten node colors when switching to light theme', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-lightened-colors.png'
@@ -267,9 +271,9 @@ test.describe(
test.describe('Context menu color adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.nodeOps.getFirstNodeRef()
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.getFirstNodeRef()
await node?.clickContextMenuOption('Colors')
})

View File

@@ -3,52 +3,52 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', { tag: '@keyboard' }, () => {
test('Should execute command', async ({ comfyPage }) => {
await comfyPage.command.registerCommand('TestCommand', () => {
window.foo = true
await comfyPage.registerCommand('TestCommand', () => {
window['foo'] = true
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
})
test('Should execute async command', async ({ comfyPage }) => {
await comfyPage.command.registerCommand('TestCommand', async () => {
await comfyPage.registerCommand('TestCommand', async () => {
await new Promise<void>((resolve) =>
setTimeout(() => {
window.foo = true
window['foo'] = true
resolve()
}, 5)
)
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true)
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
})
test('Should handle command errors', async ({ comfyPage }) => {
await comfyPage.command.registerCommand('TestCommand', () => {
await comfyPage.registerCommand('TestCommand', () => {
throw new Error('Test error')
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.getToastErrorCount()).toBe(1)
})
test('Should handle async command errors', async ({ comfyPage }) => {
await comfyPage.command.registerCommand('TestCommand', async () => {
await new Promise<void>((_resolve, reject) =>
await comfyPage.registerCommand('TestCommand', async () => {
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
reject(new Error('Test error'))
}, 5)
)
})
await comfyPage.command.executeCommand('TestCommand')
expect(await comfyPage.toast.getToastErrorCount()).toBe(1)
await comfyPage.executeCommand('TestCommand')
expect(await comfyPage.getToastErrorCount()).toBe(1)
})
})

View File

@@ -1,32 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
test('@mobile confirm dialog buttons are visible with long unbreakable text', async ({
comfyPage
}) => {
const longFilename = 'workflow_checkpoint_' + 'a'.repeat(200) + '.json'
await comfyPage.page.evaluate((msg) => {
window
.app!.extensionManager.dialog.confirm({
title: 'Confirm',
type: 'default',
message: msg
})
.catch(() => {})
}, longFilename)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
})
})

View File

@@ -1,31 +1,24 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
await comfyPage.clickEmptyLatentNode()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
})
test('Can copy and paste node with link', async ({ comfyPage }) => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.clickTextEncodeNode1()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.clipboard.copy()
await comfyPage.ctrlC()
await comfyPage.page.keyboard.press('Control+Shift+V')
await expect(comfyPage.canvas).toHaveScreenshot('copied-node-with-link.png')
})
@@ -35,9 +28,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await textBox.click()
const originalString = await textBox.inputValue()
await textBox.selectText()
await comfyPage.clipboard.copy(null)
await comfyPage.clipboard.paste(null)
await comfyPage.clipboard.paste(null)
await comfyPage.ctrlC(null)
await comfyPage.ctrlV(null)
await comfyPage.ctrlV(null)
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
})
@@ -51,7 +44,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
y: 281
}
})
await comfyPage.clipboard.copy(null)
await comfyPage.ctrlC(null)
// Empty latent node's width
await comfyPage.canvas.click({
position: {
@@ -59,7 +52,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
y: 643
}
})
await comfyPage.clipboard.paste(null)
await comfyPage.ctrlV(null)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
})
@@ -70,19 +63,15 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Paste in text area with node previously copied', async ({
comfyPage
}) => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.clipboard.copy(null)
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC(null)
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.clipboard.copy(null)
await comfyPage.clipboard.paste(null)
await comfyPage.clipboard.paste(null)
await comfyPage.ctrlC(null)
await comfyPage.ctrlV(null)
await comfyPage.ctrlV(null)
await expect(comfyPage.canvas).toHaveScreenshot(
'paste-in-text-area-with-node-previously-copied.png'
)
@@ -93,10 +82,10 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await textBox.click()
await textBox.inputValue()
await textBox.selectText()
await comfyPage.clipboard.copy(null)
await comfyPage.ctrlC(null)
// Unfocus textbox.
await comfyPage.page.mouse.click(10, 10)
await comfyPage.clipboard.paste(null)
await comfyPage.ctrlV(null)
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
})
@@ -114,19 +103,19 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Can undo paste multiple nodes as single action', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
const initialCount = await comfyPage.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
await comfyPage.canvas.click()
await comfyPage.keyboard.selectAll()
await comfyPage.ctrlA()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await comfyPage.ctrlC()
await comfyPage.ctrlV()
const pasteCount = await comfyPage.nodeOps.getGraphNodesCount()
const pasteCount = await comfyPage.getGraphNodesCount()
expect(pasteCount).toBe(initialCount * 2)
await comfyPage.keyboard.undo()
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.ctrlZ()
const undoCount = await comfyPage.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -24,7 +24,7 @@ async function verifyCustomIconSvg(iconElement: Locator) {
test.describe('Custom Icons', { tag: '@settings' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('sidebar tab icons use custom SVGs', async ({ comfyPage }) => {

View File

@@ -1,19 +1,18 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings/types'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load workflow warning', { tag: '@ui' }, () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.loadWorkflow('missing/missing_nodes')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
@@ -23,7 +22,7 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
test('Should display a warning when loading a workflow with missing nodes in subgraphs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
await comfyPage.loadWorkflow('missing/missing_nodes_in_subgraph')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
@@ -37,54 +36,42 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
})
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.page
.locator('.p-dialog')
.getByRole('button', { name: 'Close' })
.click({ force: true })
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.closeDialog()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
// Make a change to the graph
await comfyPage.canvasOps.doubleClick()
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
// Undo and redo the change
await comfyPage.keyboard.undo()
await comfyPage.ctrlZ()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await comfyPage.keyboard.redo()
await comfyPage.ctrlY()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
})
test.describe('Execution error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.queueButton.click()
await comfyPage.nextFrame()
// Wait for the error overlay to be visible
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).toBeVisible()
// Wait for the element with the .comfy-execution-error selector to be visible
const executionError = comfyPage.page.locator('.comfy-error-report')
await expect(executionError).toBeVisible()
})
})
test.describe('Missing models warning', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
await comfyPage.page.evaluate((url: string) => {
return fetch(`${url}/api/devtools/cleanup_fake_model`)
}, comfyPage.url)
@@ -93,7 +80,7 @@ test.describe('Missing models warning', () => {
test('Should display a warning when missing models are found', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await comfyPage.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
@@ -110,9 +97,7 @@ test.describe('Missing models warning', () => {
comfyPage
}) => {
// Load workflow that has a node with models metadata at the node level
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_from_node_properties'
)
await comfyPage.loadWorkflow('missing/missing_models_from_node_properties')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
@@ -161,7 +146,7 @@ test.describe('Missing models warning', () => {
{ times: 1 }
)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await comfyPage.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
@@ -172,9 +157,7 @@ test.describe('Missing models warning', () => {
}) => {
// This tests the scenario where outdated model metadata exists in the workflow
// but the actual selected models (widget values) have changed
await comfyPage.workflow.loadWorkflow(
'missing/model_metadata_widget_mismatch'
)
await comfyPage.loadWorkflow('missing/model_metadata_widget_mismatch')
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
@@ -188,7 +171,7 @@ test.describe('Missing models warning', () => {
}) => {
// The fake_model.safetensors is served by
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await comfyPage.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
@@ -207,11 +190,11 @@ test.describe('Missing models warning', () => {
let closeButton: Locator
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
await comfyPage.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await comfyPage.loadWorkflow('missing/missing_models')
checkbox = comfyPage.page.getByLabel("Don't show this again")
closeButton = comfyPage.page.getByLabel('Close')
@@ -227,7 +210,7 @@ test.describe('Missing models warning', () => {
await closeButton.click()
await changeSettingPromise
const settingValue = await comfyPage.settings.getSetting(
const settingValue = await comfyPage.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(false)
@@ -238,7 +221,7 @@ test.describe('Missing models warning', () => {
}) => {
await closeButton.click()
const settingValue = await comfyPage.settings.getSetting(
const settingValue = await comfyPage.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(true)
@@ -249,13 +232,9 @@ test.describe('Missing models warning', () => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate(
const settingsContent = comfyPage.page.locator('.settings-content')
await expect(settingsContent).toBeVisible()
const isUsableHeight = await settingsContent.evaluate(
(el) => el.clientHeight > 30
)
expect(isUsableHeight).toBeTruthy()
@@ -265,9 +244,7 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
const settingsLocator = comfyPage.page.locator('.settings-container')
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
@@ -275,26 +252,19 @@ test.describe('Settings', () => {
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await test.step('Setting should persist', async () => {
expect(await comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
maxSpeed
)
expect(await comfyPage.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"]')
await comfyPage.page.waitForSelector('.settings-container')
// Open the keybinding tab
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })
.click()
await comfyPage.page.getByLabel('Keybinding').click()
await comfyPage.page.waitForSelector(
'[placeholder="Search Keybindings..."]'
)
@@ -314,10 +284,7 @@ test.describe('Settings', () => {
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
(req) =>
req.url().includes('/api/settings') &&
!req.url().includes('/api/settings/') &&
req.method() === 'POST'
'**/api/settings/Comfy.Keybinding.NewBindings'
)
// Save keybinding
@@ -344,24 +311,18 @@ test.describe('Support', () => {
test('Should open external zendesk link with OSS tag', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Prevent loading the external page
await comfyPage.page
.context()
.route('https://support.comfy.org/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
const popupPromise = comfyPage.page.waitForEvent('popup')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const pagePromise = comfyPage.page.context().waitForEvent('page')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
const popup = await popupPromise
const newPage = await pagePromise
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
await newPage.waitForLoadState('networkidle')
await expect(newPage).toHaveURL(/.*support\.comfy\.org.*/)
const url = new URL(newPage.url())
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
await popup.close()
await newPage.close()
})
})
@@ -370,13 +331,13 @@ test.describe('Error dialog', () => {
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const graph = window.graph!
;(graph as { configure: () => void }).configure = () => {
const graph = window['graph']
graph.configure = () => {
throw new Error('Error on configure!')
}
})
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
@@ -386,7 +347,7 @@ test.describe('Error dialog', () => {
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window.app!
const app = window['app']
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
@@ -401,13 +362,9 @@ test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage
}) => {
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
const nodeNum = (await comfyPage.getNodes()).length
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const textBox = comfyPage.widgetTextBox
await textBox.click()
@@ -416,7 +373,7 @@ test.describe('Signin dialog', () => {
await textBox.press('Control+c')
await comfyPage.page.evaluate(() => {
void window.app!.extensionManager.dialog.showSignInDialog()
void window['app'].extensionManager.dialog.showSignInDialog()
})
const input = comfyPage.page.locator('#comfy-org-sign-in-password')
@@ -424,6 +381,6 @@ test.describe('Signin dialog', () => {
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
expect(await comfyPage.getNodes()).toHaveLength(nodeNum)
})
})

View File

@@ -3,12 +3,12 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('DOM Widget', { tag: '@widget' }, () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible()
})
@@ -21,7 +21,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
await expect(firstMultiline).toBeVisible()
await expect(lastMultiline).toBeVisible()
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const nodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
for (const node of nodes) {
await node.click('collapse')
}
@@ -33,8 +33,8 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
'Position update when entering focus mode',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
}
@@ -68,9 +68,9 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
.first()
await expect(textareaWidget).toBeVisible()
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.nextFrame()
let oldPos: [number, number]
@@ -85,15 +85,15 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
// --- test ---
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.nextFrame()
await checkBboxChange()
})

View File

@@ -3,33 +3,23 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test(
'Report error on unconnected slot',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.disconnectEdge()
await comfyPage.clickEmptySpace()
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.getByRole('button', { name: 'Dismiss' })
.click()
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.waitFor({ state: 'hidden' })
await comfyPage.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page.locator('.p-dialog-close-button').click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)
@@ -42,17 +32,17 @@ test.describe(
{ tag: ['@smoke', '@workflow'] },
() => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
await comfyPage.loadWorkflow('execution/partial_execution')
const input = await comfyPage.getNodeRefById(3)
const output1 = await comfyPage.getNodeRefById(1)
const output2 = await comfyPage.getNodeRefById(4)
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 comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect(async () => {
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,32 +1,23 @@
import { expect } from '@playwright/test'
import type { Settings } from '../../src/schemas/apiSchema'
import type { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
/**
* Type helper for test settings with arbitrary IDs.
* Extensions can register settings with any ID, but SettingParams.id
* is typed as keyof Settings for autocomplete. This helper allows
* arbitrary IDs in tests while keeping type safety for other fields.
*/
type TestSettingId = keyof Settings
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
window.foo = true
window['foo'] = true
}
}
],
@@ -40,15 +31,15 @@ test.describe('Topbar commands', () => {
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await 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 ({
comfyPage
}) => {
await comfyPage.command.registerCommand('foo', () => alert(1))
await comfyPage.registerCommand('foo', () => alert(1))
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
menuCommands: [
{
@@ -65,14 +56,14 @@ test.describe('Topbar commands', () => {
test('Should allow registering keybindings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const app = window.app!
const app = window['app']
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'TestCommand',
function: () => {
window.TestCommand = true
window['TestCommand'] = true
}
}
],
@@ -86,77 +77,68 @@ test.describe('Topbar commands', () => {
})
await comfyPage.page.keyboard.press('k')
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
true
)
})
test.describe('Settings', () => {
test('Should allow adding settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'TestSetting' as TestSettingId,
id: 'TestSetting',
name: 'Test Setting',
type: 'text',
defaultValue: 'Hello, world!',
onChange: () => {
window.changeCount = (window.changeCount ?? 0) + 1
window['changeCount'] = (window['changeCount'] ?? 0) + 1
}
}
]
})
})
// onChange is called when the setting is first added
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
'Hello, world!'
)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.settings.getSetting('TestSetting')).toBe(
'Hello, universe!'
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
test('Should allow setting boolean settings', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'Comfy.TestSetting' as TestSettingId,
id: 'Comfy.TestSetting',
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
onChange: () => {
window.changeCount = (window.changeCount ?? 0) + 1
window['changeCount'] = (window['changeCount'] ?? 0) + 1
}
}
]
})
})
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
false
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1)
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
expect(await comfyPage.settings.getSetting('Comfy.TestSetting')).toBe(
true
)
expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2)
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
})
test.describe('Passing through attrs to setting components', () => {
const testCases: Array<{
config: Pick<SettingParams, 'type' | 'defaultValue'> &
Partial<Omit<SettingParams, 'id' | 'type' | 'defaultValue'>>
config: Partial<SettingParams>
selector: string
}> = [
{
@@ -209,13 +191,13 @@ test.describe('Topbar commands', () => {
comfyPage
}) => {
await comfyPage.page.evaluate((config) => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'Comfy.TestSetting' as TestSettingId,
id: 'Comfy.TestSetting',
name: 'Test',
// The `disabled` attr is common to all settings components
attrs: { disabled: true },
...config
}
@@ -242,7 +224,7 @@ test.describe('Topbar commands', () => {
test.describe('About panel', () => {
test('Should allow adding badges', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
aboutPageBadges: [
{
@@ -265,71 +247,61 @@ test.describe('Topbar commands', () => {
test.describe('Dialog', () => {
test('Should allow showing a prompt dialog', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
void window
.app!.extensionManager.dialog.prompt({
void window['app'].extensionManager.dialog
.prompt({
title: 'Test Prompt',
message: 'Test Prompt Message'
})
.then((value: string | null) => {
;(window as unknown as Record<string, unknown>)['value'] = value
.then((value: string) => {
window['value'] = value
})
})
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
).toBe('Hello, world!')
await comfyPage.fillPromptDialog('Hello, world!')
expect(await comfyPage.page.evaluate(() => window['value'])).toBe(
'Hello, world!'
)
})
test('Should allow showing a confirmation dialog', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
void window
.app!.extensionManager.dialog.confirm({
void window['app'].extensionManager.dialog
.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'
})
.then((value: boolean | null) => {
;(window as unknown as Record<string, unknown>)['value'] = value
.then((value: boolean) => {
window['value'] = value
})
})
await comfyPage.confirmDialog.click('confirm')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
).toBe(true)
expect(await comfyPage.page.evaluate(() => window['value'])).toBe(true)
})
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
;(window as unknown as Record<string, unknown>)['value'] = 'foo'
void window
.app!.extensionManager.dialog.confirm({
window['value'] = 'foo'
void window['app'].extensionManager.dialog
.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'
})
.then((value: boolean | null) => {
;(window as unknown as Record<string, unknown>)['value'] = value
.then((value: boolean) => {
window['value'] = value
})
})
await comfyPage.confirmDialog.click('reject')
expect(
await comfyPage.page.evaluate(
() => (window as unknown as Record<string, unknown>)['value']
)
).toBeNull()
expect(await comfyPage.page.evaluate(() => window['value'])).toBeNull()
})
})
test.describe('Selection Toolbox', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('Should allow adding commands to selection toolbox', async ({
@@ -337,7 +309,7 @@ test.describe('Topbar commands', () => {
}) => {
// Register an extension with a selection toolbox command
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
commands: [
{
@@ -345,9 +317,7 @@ test.describe('Topbar commands', () => {
label: 'Test Command',
icon: 'pi pi-star',
function: () => {
;(window as unknown as Record<string, unknown>)[
'selectionCommandExecuted'
] = true
window['selectionCommandExecuted'] = true
}
}
],
@@ -355,7 +325,7 @@ test.describe('Topbar commands', () => {
})
})
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.selectNodes(['CLIP Text Encode (Prompt)'])
// Click the command button in the selection toolbox
const toolboxButton = comfyPage.page.locator(
@@ -363,13 +333,9 @@ test.describe('Topbar commands', () => {
)
await toolboxButton.click()
// Verify the command was executed
expect(
await comfyPage.page.evaluate(
() =>
(window as unknown as Record<string, unknown>)[
'selectionCommandExecuted'
]
)
await comfyPage.page.evaluate(() => window['selectionCommandExecuted'])
).toBe(true)
})
})

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
@@ -25,7 +25,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const originalSend = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
try {
const parsed = JSON.parse(data as string)
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
window.__capturedMessages!.clientFeatureFlags = parsed
}
@@ -37,9 +37,12 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
const flags = window.app?.api?.serverFeatureFlags?.value
if (flags && Object.keys(flags).length > 0) {
window.__capturedMessages!.serverFeatureFlags = flags
if (
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
}, 100)
@@ -93,7 +96,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window.app!.api.serverFeatureFlags.value
return window['app']!.api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
@@ -112,22 +115,26 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window.app!.api.serverSupportsFeature('supports_preview_metadata')
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
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
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(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window.app!.api.serverFeatureFlags.value
window.app!.api.serverFeatureFlags.value = {
const original = window['app']!.api.serverFeatureFlags
window['app']!.api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -136,15 +143,15 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
const results = {
bool_true: window.app!.api.serverSupportsFeature('bool_true'),
bool_false: window.app!.api.serverSupportsFeature('bool_false'),
string_value: window.app!.api.serverSupportsFeature('string_value'),
number_value: window.app!.api.serverSupportsFeature('number_value'),
null_value: window.app!.api.serverSupportsFeature('null_value')
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
string_value: window['app']!.api.serverSupportsFeature('string_value'),
number_value: window['app']!.api.serverSupportsFeature('number_value'),
null_value: window['app']!.api.serverSupportsFeature('null_value')
}
// Restore original
window.app!.api.serverFeatureFlags.value = original
window['app']!.api.serverFeatureFlags = original
return results
})
@@ -161,20 +168,20 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeature('supports_preview_metadata')
return window['app']!.api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeature('max_upload_size')
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
const defaultValue = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeature(
return window['app']!.api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
@@ -187,7 +194,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window.app!.api.getServerFeatures()
return window['app']!.api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
@@ -200,14 +207,14 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window.app!.api.getClientFeatureFlags()
const flags2 = window.app!.api.getClientFeatureFlags()
const flags1 = window['app']!.api.getClientFeatureFlags()
const flags2 = window['app']!.api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window.app!.api.getClientFeatureFlags()
const flags3 = window['app']!.api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
@@ -233,14 +240,14 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window.app!.api.getServerFeatures()
const features1 = window['app']!.api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window.app!.api.getServerFeatures()
const features2 = window['app']!.api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
@@ -279,26 +286,35 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
window.__appReadiness!.featureFlagsReceived = true
window.__appReadiness = {
...window.__appReadiness,
featureFlagsReceived: true
}
clearInterval(checkFeatureFlags)
}
}, 10)
// Monitor API initialization
const checkApi = setInterval(() => {
if (window.app?.api) {
window.__appReadiness!.apiInitialized = true
if (window['app']?.api) {
window.__appReadiness = {
...window.__appReadiness,
apiInitialized: true
}
clearInterval(checkApi)
}
}, 10)
// Monitor app initialization
const checkApp = setInterval(() => {
if (window.app?.graph) {
window.__appReadiness!.appInitialized = true
if (window['app']?.graph) {
window.__appReadiness = {
...window.__appReadiness,
appInitialized: true
}
clearInterval(checkApp)
}
}, 10)
@@ -317,8 +333,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined,
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
{
timeout: 10000
}
@@ -328,7 +344,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags.value
currentFlags: window['app']!.api.serverFeatureFlags
}
})

View File

@@ -3,24 +3,24 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Should be able to fix link input slot index after swap the input order
// 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 comfyPage.loadWorkflow('inputs/input_order_swap')
expect(
await comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
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.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
await comfyPage.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.loadWorkflow('links/bad_link')
await expect(comfyPage.getVisibleToastCount()).resolves.toBe(2)
})
})

View File

@@ -1,37 +1,34 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Set link render mode to spline to make sure it's not affected by other tests'
// side effects.
await comfyPage.settings.setSetting('Comfy.LinkRenderMode', 2)
await comfyPage.setSetting('Comfy.LinkRenderMode', 2)
// Enable canvas menu for all tests
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
})
test(
'Can toggle link visibility',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const button = comfyPage.page.getByTestId(
TestIds.canvas.toggleLinkVisibilityButton
)
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window.LiteGraph!.HIDDEN_LINK
return window['LiteGraph'].HIDDEN_LINK
})
expect(await comfyPage.settings.getSetting('Comfy.LinkRenderMode')).toBe(
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
@@ -40,18 +37,16 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
expect(
await comfyPage.settings.getSetting('Comfy.LinkRenderMode')
).not.toBe(hiddenLinkRenderMode)
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
hiddenLinkRenderMode
)
}
)
test('Toggle minimap button is clickable and has correct test id', async ({
comfyPage
}) => {
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const minimapButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapButton).toBeVisible()
await expect(minimapButton).toBeEnabled()

View File

@@ -1,17 +1,11 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '../fixtures/components/SidebarTab'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Group Node', { tag: '@node' }, () => {
@@ -19,34 +13,30 @@ test.describe('Group Node', { tag: '@node' }, () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab: NodeLibrarySidebarTab
let libraryTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
})
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
expect(await libraryTab.getFolder(groupNodeCategory).count()).toBe(1)
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialNodeCount + 1
)
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
@@ -58,7 +48,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
// Verify the node is added to the bookmarks tab
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([groupNodeBookmarkName])
// Verify the bookmark node with the same name is added to the tree
expect(await libraryTab.getNode(groupNodeName).count()).not.toBe(0)
@@ -72,7 +62,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
// Verify the node is removed from the bookmarks tab
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toHaveLength(0)
})
@@ -104,8 +94,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -115,8 +105,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
)
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
@@ -124,9 +114,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
const makeGroup = async (name, type1, type2) => {
const node1 = (await comfyPage.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.getNodeRefsByType(type2))[0]
await node1.click('title')
await node2.click('title', {
modifiers: ['Shift']
@@ -154,7 +144,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Preserves hidden input configuration when containing duplicate node types', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
await comfyPage.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
@@ -165,14 +155,16 @@ test.describe('Group Node', { tag: '@node' }, () => {
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)
} = window['app'].graph
const { nodes } = groupNodes[nodeName]
return nodes.reduce((acc: number, node) => {
return acc + node.inputs.length
}, 0)
}, groupNodeName)
const visibleInputCount = await comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node!.inputs.length
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)
@@ -186,7 +178,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
const nodes = await comfyPage.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
@@ -221,8 +213,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await comfyPage.loadWorkflow('groupnodes/legacy_group_node')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
@@ -238,7 +230,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window.LiteGraph!.registered_node_types[nodeType]
return !!window['LiteGraph'].registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
@@ -254,17 +246,17 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage: ComfyPage,
expectedCount: number
) => {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
expect(await comfyPage.getNodeRefsByType(GROUP_NODE_TYPE)).toHaveLength(
expectedCount
)
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow(WORKFLOW_NAME)
groupNode = await comfyPage.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
await groupNode.copy()
@@ -273,7 +265,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {
await comfyPage.clipboard.paste()
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 2)
})
@@ -281,12 +273,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage
}) => {
// Set setting
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await comfyPage.clipboard.paste()
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -294,15 +286,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.clipboard.paste()
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node across different workflows', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.clipboard.paste()
await comfyPage.loadWorkflow('default')
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -310,15 +302,14 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.clipboard.paste()
await comfyPage.ctrlV()
const currentGraphState = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
window['app'].graph.serialize()
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) =>
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
(workflow) => window['app'].loadGraphData(workflow),
currentGraphState
)
await comfyPage.nextFrame()
@@ -329,18 +320,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.page.keyboard.press('Alt+g')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.clickTextEncodeNode1()
await comfyPage.page.keyboard.press('Alt+g')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
})
})

View File

@@ -7,20 +7,15 @@ import {
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
// Wait for the legacy menu to appear and canvas to settle after layout shift.
await comfyPage.page.locator('.comfy-menu').waitFor({ state: 'visible' })
await comfyPage.nextFrame()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can select/delete all items', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await expect(comfyPage.canvas).toHaveScreenshot('selected-all.png')
await comfyPage.canvas.press('Delete')
@@ -28,7 +23,7 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
})
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
@@ -56,13 +51,11 @@ test.describe('Node Interaction', () => {
test(`Can add multiple nodes to selection using ${modifier}+Click`, async ({
comfyPage
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
for (const node of clipNodes) {
await node.click('title', { modifiers: [modifier] })
}
const selectedNodeCount =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
const selectedNodeCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedNodeCount).toBe(clipNodes.length)
})
})
@@ -72,15 +65,9 @@ test.describe('Node Interaction', () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode2
})
await comfyPage.nextFrame()
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
}
)
@@ -93,7 +80,7 @@ test.describe('Node Interaction', () => {
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.page.keyboard.down('Meta')
await comfyPage.canvasOps.dragAndDrop(
await comfyPage.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
@@ -107,10 +94,9 @@ test.describe('Node Interaction', () => {
}
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
@@ -118,8 +104,7 @@ test.describe('Node Interaction', () => {
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
comfyPage
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
@@ -131,7 +116,7 @@ test.describe('Node Interaction', () => {
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.command.executeCommand(
await comfyPage.executeCommand(
`Comfy.Canvas.MoveSelectedNodes.${direction}`
)
await comfyPage.canvas.press(`Control+Arrow${direction}`)
@@ -170,20 +155,14 @@ test.describe('Node Interaction', () => {
})
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.dragNode2()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'no action'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'no action'
)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
})
// Test both directions of edge connection.
@@ -191,11 +170,11 @@ test.describe('Node Interaction', () => {
test(`Can disconnect/connect edge ${reverse ? 'reverse' : 'normal'}`, async ({
comfyPage
}) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.canvasOps.connectEdge({ reverse })
await comfyPage.connectEdge({ reverse })
// Move mouse to empty area to avoid slot highlight.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
@@ -204,14 +183,14 @@ test.describe('Node Interaction', () => {
})
test('Can move link', async ({ comfyPage }) => {
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode2InputSlot,
DefaultGraphPositions.clipTextEncodeNode1InputSlot
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputSlot,
comfyPage.clipTextEncodeNode1InputSlot
)
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
})
@@ -220,15 +199,15 @@ test.describe('Node Interaction', () => {
test.skip('Can copy link by shift-drag existing link', async ({
comfyPage
}) => {
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode2InputLinkPath,
DefaultGraphPositions.clipTextEncodeNode1InputSlot
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputLinkPath,
comfyPage.clipTextEncodeNode1InputSlot
)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
@@ -238,11 +217,11 @@ test.describe('Node Interaction', () => {
comfyPage,
comfyMouse
}) => {
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
await comfyMouse.move(comfyPage.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(comfyPage.clipTextEncodeNode2InputSlot)
await expect(comfyPage.canvas).toHaveScreenshot('snapped-highlighted.png')
})
})
@@ -251,7 +230,7 @@ test.describe('Node Interaction', () => {
'Can adjust widget value',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nodeOps.adjustEmptyLatentWidth()
await comfyPage.adjustWidgetValue()
await expect(comfyPage.canvas).toHaveScreenshot(
'adjusted-widget-value.png'
)
@@ -259,7 +238,7 @@ test.describe('Node Interaction', () => {
)
test('Link snap to slot', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('links/snap_to_slot')
await comfyPage.loadWorkflow('links/snap_to_slot')
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
const outputSlotPos = {
@@ -270,7 +249,7 @@ test.describe('Node Interaction', () => {
x: 748,
y: 77
}
await comfyPage.canvasOps.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await comfyPage.dragAndDrop(outputSlotPos, samplerNodeCenterPos)
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
})
@@ -279,7 +258,7 @@ test.describe('Node Interaction', () => {
'Can batch move links by drag with shift',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
await comfyPage.loadWorkflow('links/batch_move_links')
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
const outputSlot1Pos = {
@@ -292,7 +271,7 @@ test.describe('Node Interaction', () => {
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -325,18 +304,12 @@ test.describe('Node Interaction', () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
})
await comfyPage.nextFrame()
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNodeToggler
})
await comfyPage.nextFrame()
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
@@ -372,7 +345,7 @@ test.describe('Node Interaction', () => {
x: 167,
y: 143
}
await comfyPage.workflow.loadWorkflow('nodes/single_save_image_node')
await comfyPage.loadWorkflow('nodes/single_save_image_node')
await comfyPage.canvas.click({
position: textWidgetPos
})
@@ -392,7 +365,7 @@ test.describe('Node Interaction', () => {
'Can double click node title to edit',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
@@ -409,7 +382,7 @@ test.describe('Node Interaction', () => {
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.canvas.dblclick({
position: {
x: 50,
@@ -424,11 +397,8 @@ test.describe('Node Interaction', () => {
'Can group selected nodes',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
10
)
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.setSetting('Comfy.GroupSelectedNodes.Padding', 10)
await comfyPage.select2Nodes()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.press('KeyG')
await comfyPage.page.keyboard.up('Control')
@@ -446,10 +416,10 @@ test.describe('Node Interaction', () => {
'Can fit group to contents',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await comfyPage.keyboard.selectAll()
await comfyPage.loadWorkflow('groups/oversized_group')
await comfyPage.ctrlA()
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-fit-to-contents.png'
@@ -458,15 +428,11 @@ test.describe('Node Interaction', () => {
)
test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.command.executeCommand(
'Comfy.Canvas.ToggleSelectedNodes.Pin'
)
await comfyPage.select2Nodes()
await comfyPage.executeCommand('Comfy.Canvas.ToggleSelectedNodes.Pin')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-pinned.png')
await comfyPage.command.executeCommand(
'Comfy.Canvas.ToggleSelectedNodes.Pin'
)
await comfyPage.executeCommand('Comfy.Canvas.ToggleSelectedNodes.Pin')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
})
@@ -475,7 +441,7 @@ test.describe('Node Interaction', () => {
'Can bypass/unbypass nodes with keyboard shortcut',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.select2Nodes()
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
@@ -488,7 +454,7 @@ test.describe('Node Interaction', () => {
test.describe('Group Interaction', { tag: '@screenshot' }, () => {
test('Can double click group title to edit', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/single_group')
await comfyPage.loadWorkflow('groups/single_group')
await comfyPage.canvas.dblclick({
position: {
x: 50,
@@ -504,16 +470,16 @@ test.describe('Group Interaction', { tag: '@screenshot' }, () => {
test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out', async ({ comfyPage }) => {
await comfyPage.canvasOps.zoom(-100)
await comfyPage.zoom(-100)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
await comfyPage.canvasOps.zoom(200)
await comfyPage.zoom(200)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
})
test('Can zoom very far out', async ({ comfyPage }) => {
await comfyPage.canvasOps.zoom(100, 12)
await comfyPage.zoom(100, 12)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-very-far-out.png')
await comfyPage.canvasOps.zoom(-100, 12)
await comfyPage.zoom(-100, 12)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-back-in.png')
})
@@ -522,11 +488,11 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
}) => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await comfyPage.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await comfyPage.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await comfyPage.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-default-ctrl-shift.png'
)
@@ -537,35 +503,35 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.05)
await comfyPage.canvasOps.zoom(-100, 4)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.05)
await comfyPage.zoom(-100, 4)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-low-zoom-speed.png'
)
await comfyPage.canvasOps.zoom(100, 8)
await comfyPage.zoom(100, 8)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-out-low-zoom-speed.png'
)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('Can zoom in/out after increasing canvas zoom speed', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.5)
await comfyPage.canvasOps.zoom(-100, 4)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.5)
await comfyPage.zoom(-100, 4)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-high-zoom-speed.png'
)
await comfyPage.canvasOps.zoom(100, 8)
await comfyPage.zoom(100, 8)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-out-high-zoom-speed.png'
)
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('Can pan', async ({ comfyPage }) => {
await comfyPage.canvasOps.pan({ x: 200, y: 200 })
await comfyPage.pan({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
@@ -636,9 +602,9 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
})
test('Can pan when dragging a link', async ({ comfyPage, comfyMouse }) => {
const posSlot1 = DefaultGraphPositions.clipTextEncodeNode1InputSlot
const posSlot1 = comfyPage.clipTextEncodeNode1InputSlot
await comfyMouse.move(posSlot1)
const posEmpty = DefaultGraphPositions.emptySpace
const posEmpty = comfyPage.emptySpace
await comfyMouse.drag(posEmpty)
await expect(comfyPage.canvas).toHaveScreenshot('dragging-link1.png')
@@ -657,23 +623,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can pan very far and back', async ({ comfyPage }) => {
// intentionally slice the edge of where the clip text encode dom widgets are
await comfyPage.canvasOps.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
await comfyPage.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-one.png')
await comfyPage.canvasOps.pan({ x: -200, y: 0 }, { x: 1000, y: 10 })
await comfyPage.pan({ x: -200, y: 0 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-two.png')
await comfyPage.canvasOps.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 })
await comfyPage.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-far-away.png')
await comfyPage.canvasOps.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 })
await comfyPage.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-from-far.png')
await comfyPage.canvasOps.pan({ x: 200, y: 0 }, { x: 1000, y: 10 })
await comfyPage.pan({ x: 200, y: 0 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-two.png')
await comfyPage.canvasOps.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await comfyPage.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png')
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.closeMenu()
await comfyPage.canvasOps.panWithTouch({ x: 200, y: 200 })
await comfyPage.panWithTouch({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-touch.png')
})
})
@@ -686,41 +652,41 @@ test.describe('Widget Interaction', () => {
await expect(textBox).toHaveValue('')
await textBox.fill('Hello World')
await expect(textBox).toHaveValue('Hello World')
await comfyPage.keyboard.undo(null)
await comfyPage.ctrlZ(null)
await expect(textBox).toHaveValue('')
})
test('Undo attention edit', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EditAttention.Delta', 0.05)
await comfyPage.setSetting('Comfy.EditAttention.Delta', 0.05)
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('1girl')
await expect(textBox).toHaveValue('1girl')
await textBox.selectText()
await comfyPage.keyboard.moveUp(null)
await comfyPage.ctrlArrowUp(null)
await expect(textBox).toHaveValue('(1girl:1.05)')
await comfyPage.keyboard.undo(null)
await comfyPage.ctrlZ(null)
await expect(textBox).toHaveValue('1girl')
})
})
test.describe('Load workflow', { tag: '@screenshot' }, () => {
test('Can load workflow with string node id', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/string_node_id')
await comfyPage.loadWorkflow('nodes/string_node_id')
await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png')
})
test('Can load workflow with ("STRING",) input node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('inputs/string_input')
await comfyPage.loadWorkflow('inputs/string_input')
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.loadWorkflow('nodes/single_ksampler')
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png')
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png')
@@ -729,32 +695,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
test('Restore workflow on reload (modify workflow)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await comfyPage.loadWorkflow('nodes/single_ksampler')
const node = (await comfyPage.getFirstNodeRef())!
await node.click('collapse')
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
// Wait for V2 persistence debounce to save the modified workflow
const start = Date.now()
await comfyPage.page.waitForFunction((since) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
return true
}
} catch {
// ignore
}
}
return false
}, start)
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
@@ -769,7 +716,7 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
@@ -777,24 +724,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for sessionStorage to persist the workflow paths before reloading
// V2 persistence uses sessionStorage with client-scoped keys
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
return true
}
}
return false
})
// Wait for localStorage to persist the workflow paths before reloading
await comfyPage.page.waitForFunction(
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
)
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after reload', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
@@ -807,7 +747,7 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
test('Restores sidebar workflows after reload', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
@@ -830,40 +770,34 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',
false
)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.setSetting('Comfy.EnableWorkflowViewRestore', false)
await comfyPage.loadWorkflow('nodes/single_ksampler')
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler_fit.png')
})
})
test.describe('Load duplicate workflow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('A workflow can be loaded multiple times in a row', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.loadWorkflow('nodes/single_ksampler')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
})
})
test.describe('Viewport settings', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.setupWorkflowsDirectory({})
})
test('Keeps viewport settings when changing tabs', async ({
@@ -873,7 +807,7 @@ test.describe('Viewport settings', () => {
const changeTab = async (tab: Locator) => {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.emptySpace)
await comfyMouse.move(comfyPage.emptySpace)
// If tooltip is visible, wait for it to hide
await expect(
@@ -882,13 +816,11 @@ test.describe('Viewport settings', () => {
}
// Screenshot the canvas element
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await comfyPage.nextFrame()
@@ -905,7 +837,7 @@ test.describe('Viewport settings', () => {
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
await changeTab(tabB)
await comfyMouse.move(DefaultGraphPositions.emptySpace)
await comfyMouse.move(comfyPage.emptySpace)
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
@@ -933,19 +865,13 @@ test.describe('Viewport settings', () => {
test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test.describe('Legacy Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
)
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
})
test('Left-click drag in empty area should pan canvas', async ({
comfyPage
}) => {
await comfyPage.canvasOps.dragAndDrop(
{ x: 50, y: 50 },
{ x: 150, y: 150 }
)
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-left-drag-pan.png'
)
@@ -978,11 +904,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
})
test('Left-click on node should not pan canvas', async ({ comfyPage }) => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-click-node-select.png'
@@ -992,22 +915,18 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test.describe('Standard Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'standard'
)
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'standard')
})
test('Left-click drag in empty area should select nodes', async ({
comfyPage
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.canvasOps.dragAndDrop(
await comfyPage.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
@@ -1018,7 +937,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
}
)
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(clipNodes.length)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-left-drag-select.png'
@@ -1058,11 +977,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Left-click on node should select node (not start selection box)', async ({
comfyPage
}) => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
const selectedCount = await comfyPage.nodeOps.getSelectedGraphNodesCount()
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-click-node-select.png'
@@ -1075,10 +991,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.nextFrame()
await comfyPage.page.keyboard.down('Space')
await comfyPage.canvasOps.dragAndDrop(
{ x: 50, y: 50 },
{ x: 150, y: 150 }
)
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.page.keyboard.up('Space')
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-space-drag-pan.png'
@@ -1088,12 +1001,11 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Space key overrides default left-click behavior', async ({
comfyPage
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const offset = 64
await comfyPage.canvasOps.dragAndDrop(
await comfyPage.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
@@ -1105,16 +1017,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
const selectedCountAfterDrag =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterDrag).toBeGreaterThan(0)
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.clickEmptySpace()
const selectedCountAfterClear =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterClear).toBe(0)
await comfyPage.page.keyboard.down('Space')
await comfyPage.canvasOps.dragAndDrop(
await comfyPage.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
@@ -1127,7 +1039,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.keyboard.up('Space')
const selectedCountAfterSpaceDrag =
await comfyPage.nodeOps.getSelectedGraphNodesCount()
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterSpaceDrag).toBe(0)
})
})
@@ -1135,10 +1047,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.MouseWheelScroll',
'panning'
)
await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning')
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
@@ -1176,17 +1085,11 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Multiple modifier keys work correctly in legacy mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
)
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop(
{ x: 50, y: 50 },
{ x: 150, y: 150 }
)
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.page.keyboard.up('Shift')
await comfyPage.page.keyboard.up('Alt')
@@ -1206,10 +1109,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
})
}
await comfyPage.settings.setSetting(
'Comfy.Canvas.NavigationMode',
'legacy'
)
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -3,22 +3,22 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', { tag: '@keyboard' }, () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.command.registerKeybinding({ key: 'k' }, () => {
window.TestCommand = true
await comfyPage.registerKeybinding({ key: 'k' }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('k')
await expect(textBox).toHaveValue('k')
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
undefined
)
})
@@ -26,8 +26,8 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
test('Should not trigger modifier keybinding when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.command.registerKeybinding({ key: 'k', ctrl: true }, () => {
window.TestCommand = true
await comfyPage.registerKeybinding({ key: 'k', ctrl: true }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
@@ -35,21 +35,23 @@ test.describe('Keybindings', { tag: '@keyboard' }, () => {
await textBox.fill('q')
await textBox.press('Control+k')
await expect(textBox).toHaveValue('q')
expect(await 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 ({
comfyPage
}) => {
await comfyPage.command.registerKeybinding({ key: 'Ctrl+v' }, () => {
window.TestCommand = true
await comfyPage.registerKeybinding({ key: 'Ctrl+v' }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.press('Control+v')
await expect(textBox).toBeFocused()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
undefined
)
})

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
function listenForEvent(): Promise<Event> {
@@ -17,7 +17,7 @@ function listenForEvent(): Promise<Event> {
test.describe('Canvas Event', { tag: '@canvas' }, () => {
test('Emit litegraph:canvas empty-release', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const disconnectPromise = comfyPage.canvasOps.disconnectEdge()
const disconnectPromise = comfyPage.disconnectEdge()
const event = await eventPromise
await disconnectPromise
@@ -29,7 +29,7 @@ test.describe('Canvas Event', { tag: '@canvas' }, () => {
test('Emit litegraph:canvas empty-double-click', async ({ comfyPage }) => {
const eventPromise = comfyPage.page.evaluate(listenForEvent)
const doubleClickPromise = comfyPage.canvasOps.doubleClick()
const doubleClickPromise = comfyPage.doubleClickCanvas()
const event = await eventPromise
await doubleClickPromise

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe(
@@ -16,7 +16,6 @@ test.describe(
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow_itxt.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.
@@ -33,7 +32,7 @@ test.describe(
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
await comfyPage.dragAndDropFile(`workflowInMedia/${fileName}`)
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})
@@ -45,7 +44,7 @@ test.describe(
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
comfyPage
}) => {
await comfyPage.dragDrop.dragAndDropURL(url)
await comfyPage.dragAndDropURL(url)
const readableName = url.split('/').pop()
await expect(comfyPage.canvas).toHaveScreenshot(
`dropped_workflow_url_${readableName}.png`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
@@ -11,11 +11,11 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
comfyPage
}) => {
// Load a workflow with some nodes to render
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
// Get initial LOD state and settings
const initialState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale,
@@ -32,11 +32,11 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
// 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.zoom(120, 5) // Zoom out 5 steps
await comfyPage.nextFrame()
const aboveThresholdState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
@@ -49,12 +49,12 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
}
// Zoom out more to trigger LOD (below threshold)
await comfyPage.canvasOps.zoom(120, 5) // Zoom out 5 more steps
await comfyPage.zoom(120, 5) // Zoom out 5 more steps
await comfyPage.nextFrame()
// Check that LOD is now active
const zoomedOutState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
@@ -65,12 +65,12 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
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.zoom(-120, 15) // Zoom in 15 steps
await comfyPage.nextFrame()
// Check that LOD is now inactive
const zoomedInState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
@@ -84,17 +84,14 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
test('Should update threshold when font size setting changes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
// Change the font size setting to 14px (more aggressive LOD)
await comfyPage.settings.setSetting(
'LiteGraph.Canvas.MinFontSizeForLOD',
14
)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 14)
// Check that font size updated
const newState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
minFontSize: canvas.min_font_size_for_lod
}
@@ -105,16 +102,16 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
// At default zoom, LOD should still be inactive (scale is exactly 1.0, not less than)
const lodState = await comfyPage.page.evaluate(() => {
return window.app!.canvas.low_quality
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.zoom(120, 1) // Zoom out 1 step
await comfyPage.nextFrame()
const afterZoom = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
@@ -128,18 +125,18 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
test('Should disable LOD when font size is set to 0', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
// Disable LOD by setting font size to 0
await comfyPage.settings.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
// Zoom out significantly
await comfyPage.canvasOps.zoom(120, 20) // Zoom out 20 steps
await comfyPage.zoom(120, 20) // Zoom out 20 steps
await comfyPage.nextFrame()
// LOD should remain disabled even at very low zoom
const state = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale,
@@ -157,15 +154,15 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
// Load a workflow with text-heavy nodes for clear visual difference
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
// Set zoom level clearly below the threshold to ensure LOD activates
const targetZoom = 0.4 // Well below default threshold of ~0.571
// Zoom to target level
await comfyPage.page.evaluate((zoom) => {
window.app!.canvas.ds.scale = zoom
window.app!.canvas.setDirty(true, true)
window['app'].canvas.ds.scale = zoom
window['app'].canvas.setDirty(true, true)
}, targetZoom)
await comfyPage.nextFrame()
@@ -175,7 +172,7 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
)
const lowQualityState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale
@@ -184,10 +181,7 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
expect(lowQualityState.lowQuality).toBe(true)
// Disable LOD to see high quality at same zoom
await comfyPage.settings.setSetting(
'LiteGraph.Canvas.MinFontSizeForLOD',
0
)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
await comfyPage.nextFrame()
// Take snapshot with LOD disabled (full quality at same zoom)
@@ -196,7 +190,7 @@ test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
)
const highQualityState = await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const canvas = window['app'].canvas
return {
lowQuality: canvas.low_quality,
scale: canvas.ds.scale

View File

@@ -4,14 +4,14 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Menu', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can register sidebar tab', async ({ comfyPage }) => {
const initialChildrenCount = await comfyPage.menu.buttons.count()
await comfyPage.page.evaluate(async () => {
window.app!.extensionManager.registerSidebarTab({
window['app'].extensionManager.registerSidebarTab({
id: 'search',
icon: 'pi pi-search',
title: 'search',
@@ -30,11 +30,11 @@ test.describe('Menu', { tag: '@ui' }, () => {
test.describe('Workflows topbar tabs', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.workflow.setupWorkflowsDirectory({})
await comfyPage.setupWorkflowsDirectory({})
})
test('Can show opened workflows', async ({ comfyPage }) => {
@@ -101,12 +101,13 @@ test.describe('Menu', { tag: '@ui' }, () => {
const checkmark = bottomPanelMenuItem.locator('.pi-check')
// Check initial state of bottom panel (it's initially hidden)
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
const bottomPanel = comfyPage.page.locator('.bottom-panel')
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click Bottom Panel to toggle it on
await bottomPanelItem.click()
// Verify menu is still visible after clicking
@@ -114,7 +115,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is now visible
await expect(bottomPanel.root).toBeVisible()
await expect(bottomPanel).toBeVisible()
// Checkmark should now be visible (panel is shown)
await expect(checkmark).not.toHaveClass(/invisible/)
@@ -127,7 +128,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
@@ -154,7 +155,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
test('Can catch error when executing command', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
window['app'].registerExtension({
name: 'TestExtension1',
commands: [
{
@@ -174,7 +175,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
@@ -213,9 +214,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
await comfyPage.attachScreenshot('theme-menu-light-active')
// Verify ColorPalette setting is set to "light"
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
'light'
)
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('light')
// Close menu to see theme change
await topbar.closeTopbarMenu()
@@ -239,9 +238,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
await comfyPage.attachScreenshot('theme-menu-dark-active')
// Verify ColorPalette setting is set to "dark"
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
'dark'
)
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('dark')
// Close menu
await topbar.closeTopbarMenu()
@@ -254,20 +251,16 @@ test.describe('Menu', { tag: '@ui' }, () => {
test(`Can migrate deprecated menu positions (${position})`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
'Top'
)
await comfyPage.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Top')
})
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setup()
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
'Top'
)
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Top')
})
})
})

View File

@@ -1,15 +1,16 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Minimap', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
@@ -34,9 +35,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
@@ -46,9 +45,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()

View File

@@ -1,24 +1,22 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { expect } from '@playwright/test'
test.describe(
'Mobile Baseline Snapshots',
{ tag: ['@mobile', '@screenshot'] },
() => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 5000 })
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
@@ -32,9 +30,7 @@ test.describe(
'mobile-settings-dialog.png',
{
mask: [
comfyPage.settingDialog.root.getByTestId(
TestIds.user.currentUserIndicator
)
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
]
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -5,14 +5,14 @@ import { NodeBadgeMode } from '../../src/types/nodeSource'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const LGraphBadge = window.LGraphBadge!
const app = window.app as ComfyApp
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
const nodes = graph.nodes
@@ -28,8 +28,8 @@ test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add multiple badges', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const LGraphBadge = window.LGraphBadge!
const app = window.app as ComfyApp
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
const nodes = graph.nodes
@@ -48,8 +48,8 @@ test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge left-side', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const LGraphBadge = window.LGraphBadge!
const app = window.app as ComfyApp
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
const nodes = graph.nodes
@@ -73,17 +73,11 @@ test.describe(
Object.values(NodeBadgeMode).forEach(async (mode) => {
test(`Shows node badges (${mode})`, async ({ comfyPage }) => {
// Execution error workflow has both custom node and core node.
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.NodeSourceBadgeMode',
mode
)
await comfyPage.settings.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
mode
)
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.setSetting('Comfy.NodeBadge.NodeSourceBadgeMode', mode)
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', mode)
await comfyPage.nextFrame()
await comfyPage.canvasOps.resetView()
await comfyPage.resetView()
await expect(comfyPage.canvas).toHaveScreenshot(
`node-badge-${mode}.png`
)
@@ -99,14 +93,14 @@ test.describe(
test('Can show node badge with unknown color palette', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'unknown')
await comfyPage.setSetting('Comfy.ColorPalette', 'unknown')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-unknown-color-palette.png'
)
@@ -115,14 +109,14 @@ test.describe(
test('Can show node badge with light color palette', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'
)

View File

@@ -3,40 +3,40 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
test('No shape specified', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/optional_input_no_shape')
await comfyPage.loadWorkflow('inputs/optional_input_no_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Wrong shape specified', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/optional_input_wrong_shape')
await comfyPage.loadWorkflow('inputs/optional_input_wrong_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Correct shape specified', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/optional_input_correct_shape')
await comfyPage.loadWorkflow('inputs/optional_input_correct_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Force input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/force_input')
await comfyPage.loadWorkflow('inputs/force_input')
await expect(comfyPage.canvas).toHaveScreenshot('force_input.png')
})
test('Default input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/default_input')
await comfyPage.loadWorkflow('inputs/default_input')
await expect(comfyPage.canvas).toHaveScreenshot('default_input.png')
})
test('Only optional inputs', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/only_optional_inputs')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await comfyPage.loadWorkflow('inputs/only_optional_inputs')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
@@ -47,45 +47,37 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/old_workflow_converted_input')
const node = await comfyPage.nodeOps.getNodeRefById('1')
const inputs = (await node.getProperty('inputs')) as {
name: string
link?: number | null
}[]
await comfyPage.loadWorkflow('inputs/old_workflow_converted_input')
const node = await comfyPage.getNodeRefById('1')
const inputs = await node.getProperty('inputs')
const vaeInput = inputs.find((w) => w.name === 'vae')
const convertedInput = inputs.find((w) => w.name === 'strength')
expect(vaeInput).toBeDefined()
expect(convertedInput).toBeDefined()
expect(vaeInput!.link).toBeNull()
expect(convertedInput!.link).not.toBeNull()
expect(vaeInput.link).toBeNull()
expect(convertedInput.link).not.toBeNull()
})
test('Renamed converted input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/renamed_converted_widget')
const node = await comfyPage.nodeOps.getNodeRefById('3')
const inputs = (await node.getProperty('inputs')) as { name: string }[]
await comfyPage.loadWorkflow('inputs/renamed_converted_widget')
const node = await comfyPage.getNodeRefById('3')
const inputs = await node.getProperty('inputs')
const renamedInput = inputs.find((w) => w.name === 'breadth')
expect(renamedInput).toBeUndefined()
})
test('slider', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/simple_slider')
await comfyPage.loadWorkflow('inputs/simple_slider')
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingNodesWarning',
false
)
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
)
await comfyPage.setSetting('Comfy.Workflow.ShowMissingNodesWarning', false)
await comfyPage.loadWorkflow('missing/missing_nodes_converted_widget')
await expect(comfyPage.canvas).toHaveScreenshot(
'missing_nodes_converted_widget.png'
)
})
test('dynamically added input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')
await comfyPage.loadWorkflow('inputs/dynamically_added_input')
await expect(comfyPage.canvas).toHaveScreenshot(
'dynamically_added_input.png'
)

View File

@@ -1,162 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
}
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, nodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {
test.describe(`Ghost node placement (${mode} mode)`, () => {
test.beforeEach(async ({ comfyPage }) => {
await setVueMode(comfyPage, mode === 'vue')
})
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const result = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
const canvas = window.app!.canvas
const rect = canvas.canvas.getBoundingClientRect()
const cursorCanvasX =
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
const cursorCanvasY =
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
return {
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
diffY: node.pos[1] - 10 - cursorCanvasY
}
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
})
test('left-click confirms ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY)
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).not.toBeNull()
expect(after!.ghost).toBe(false)
})
test('Escape cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Delete cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('right-click cancels ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
})
}

View File

@@ -12,7 +12,7 @@ async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const app = window.app!
const app = window['app']!
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
@@ -26,19 +26,17 @@ async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Selection Toolbox', () => {
test('Should open help menu for selected node', async ({ comfyPage }) => {
// Load a workflow with a node
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('default')
// Select a single node (KSampler) using node references
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
@@ -89,9 +87,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await ksamplerNode.hover()
// Click the help button
const helpButton = ksamplerNode.getByRole('button', {
name: /learn more/i
})
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await expect(helpButton).toBeVisible()
await helpButton.click()
@@ -121,9 +117,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
.filter({ hasText: 'KSampler' })
.first()
await ksamplerNode.hover()
const helpButton = ksamplerNode.getByRole('button', {
name: /learn more/i
})
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await helpButton.click()
// Verify help page is shown
@@ -147,7 +141,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.describe('Help Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('Should display loading state while fetching help', async ({
@@ -163,9 +157,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
})
// Load workflow and select a node
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
@@ -196,9 +189,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
})
// Load workflow and select a node
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
@@ -234,9 +226,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
})
})
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
@@ -285,9 +276,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
})
})
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
@@ -333,7 +323,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
comfyPage
}) => {
// First load workflow with custom node
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
await comfyPage.loadWorkflow('groupnodes/group_node_v1.3.3')
// Mock custom node documentation with fallback
await comfyPage.page.route(
@@ -357,10 +347,10 @@ This is documentation for a custom node.
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes.map((n) => n.id)
return window['app']!.graph!.nodes.map((n) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.nodeOps.getNodeRefById(nodeRefs[0])
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
await selectNodeWithPan(comfyPage, firstNode)
}
@@ -403,9 +393,8 @@ This is documentation for a custom node.
})
})
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
@@ -471,11 +460,10 @@ This is English documentation.
})
// Set locale to Japanese
await comfyPage.settings.setSetting('Comfy.Locale', 'ja')
await comfyPage.setSetting('Comfy.Locale', 'ja')
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
@@ -490,7 +478,7 @@ This is English documentation.
await expect(helpPage).toContainText('これは日本語のドキュメントです')
// Reset locale
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
await comfyPage.setSetting('Comfy.Locale', 'en')
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {
@@ -499,9 +487,8 @@ This is English documentation.
await route.abort('failed')
})
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
@@ -543,12 +530,11 @@ This is English documentation.
}
)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select KSampler first
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
@@ -563,7 +549,7 @@ This is English documentation.
await expect(helpPage).toContainText('This is KSampler documentation')
// Now select Checkpoint Loader
const checkpointNodes = await comfyPage.nodeOps.getNodeRefsByType(
const checkpointNodes = await comfyPage.getNodeRefsByType(
'CheckpointLoaderSimple'
)
await selectNodeWithPan(comfyPage, checkpointNodes[0])

View File

@@ -2,42 +2,32 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node search box', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test(`Can trigger on empty canvas double click`, async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test(`Can trigger on group body double click`, async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
await comfyPage.loadWorkflow('groups/single_group_only')
await comfyPage.page.mouse.dblclick(50, 50, { delay: 5 })
await comfyPage.nextFrame()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test('Can trigger on link release', async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
@@ -47,27 +37,24 @@ test.describe('Node search box', { tag: '@node' }, () => {
// Start fresh to test new user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate new user with 1.24.1+ installed version
await comfyPage.settings.setSetting('Comfy.InstalledVersion', '1.24.1')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test('Can auto link node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
suggestionIndex: 0
@@ -79,18 +66,18 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Can auto link batch moved node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)
const checkpointNode = await comfyPage.nodeOps.getNodeRefById(4)
const clipOutputSlot = await checkpointNode.getOutput(1)
const outputSlotPos = await clipOutputSlot.getPosition()
// Use a position in the empty canvas area (top-left corner)
const emptySpacePos = { x: 5, y: 5 }
await comfyPage.loadWorkflow('links/batch_move_links')
const outputSlot1Pos = {
x: 304,
y: 127
}
const emptySpacePos = {
x: 5,
y: 5
}
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop(outputSlotPos, emptySpacePos)
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
await comfyPage.page.keyboard.up('Shift')
// Select the second item as the first item is always reroute
@@ -107,7 +94,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Link release connecting to node with no slots',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
@@ -119,7 +106,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
test('Has correct aria-labels on search results', async ({ comfyPage }) => {
const node = 'Load Checkpoint'
await comfyPage.canvasOps.doubleClick()
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(node)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
@@ -130,7 +117,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
test('@mobile Can trigger on empty canvas tap', async ({ comfyPage }) => {
await comfyPage.closeMenu()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.loadWorkflow('nodes/single_ksampler')
const screenCenter = {
x: 200,
y: 400
@@ -145,10 +132,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
})
test.describe('Filtering', () => {
const expectFilterChips = async (
comfyPage: ComfyPage,
expectedTexts: string[]
) => {
const expectFilterChips = async (comfyPage, expectedTexts: string[]) => {
const chips = comfyPage.searchBox.filterChips
// Check that the number of chips matches the expected count
@@ -165,7 +149,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await comfyPage.doubleClickCanvas()
})
test('Can add filter', async ({ comfyPage }) => {
@@ -221,14 +205,6 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
})
test('Does not add duplicate filter with same type and value', async ({
comfyPage
}) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.removeFilter(0)
@@ -265,7 +241,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
test.describe('Input focus behavior', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await comfyPage.doubleClickCanvas()
})
test('focuses input after adding a filter', async ({ comfyPage }) => {
@@ -283,25 +259,16 @@ test.describe('Node search box', { tag: '@node' }, () => {
test.describe('Release context menu', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'context menu'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
})
test(
'Can trigger on link release',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
@@ -320,10 +287,9 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage, comfyMouse }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.contextMenu.clickMenuItem('Search')
await comfyPage.nextFrame()
await comfyPage.clickContextMenuItem('Search')
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
@@ -337,14 +303,11 @@ test.describe('Release context menu', { tag: '@node' }, () => {
// Start fresh to test existing user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate existing user with pre-1.24.1 version
await comfyPage.settings.setSetting('Comfy.InstalledVersion', '1.23.0')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.setSetting('Comfy.InstalledVersion', '1.23.0')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
// Context menu should appear, search box should not
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
@@ -356,18 +319,12 @@ test.describe('Release context menu', { tag: '@node' }, () => {
}) => {
// Start fresh and simulate new user who should get search box by default
await comfyPage.setup({ clearStorage: true })
await comfyPage.settings.setSetting('Comfy.InstalledVersion', '1.24.1')
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
// But explicitly set to context menu (overriding versioned default)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'context menu'
)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.disconnectEdge()
// Context menu should appear due to explicit setting, not search box
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 114 KiB

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