Compare commits

..

14 Commits

Author SHA1 Message Date
Jacob Segal
ada4f1d533 Add Playwright tests for async execution 2025-07-17 18:27:09 -07:00
Jacob Segal
aae20d9417 Merge remote-tracking branch 'origin/main' into js/async_nodes 2025-07-16 10:41:50 -07:00
Jacob Segal
9f4abbc3af Rename HierarchicalNodeId to NodeExecutionId 2025-07-11 19:04:48 -07:00
Jacob Segal
0887bb6654 Address PR feedback 2025-07-11 16:01:09 -07:00
github-actions
868e047272 Update locales [skip ci] 2025-07-10 22:44:44 +00:00
Jacob Segal
abb82e5fd1 Merge remote-tracking branch 'upstream/main' into js/async_nodes 2025-07-10 15:35:08 -07:00
Jacob Segal
db70265e16 Fix some unit tests 2025-07-08 01:22:13 -07:00
github-actions
c8137f7f98 Update locales [skip ci] 2025-07-08 07:48:52 +00:00
Jacob Segal
132a9dbb5f Add a feature flags message to reduce bandwidth 2025-07-08 00:23:10 -07:00
Jacob Segal
f076a1c422 Fix bug with browser tab title 2025-07-07 19:27:17 -07:00
Jacob Segal
f6c65d3fe7 Progress bars work in subgraphs 2025-07-07 17:16:38 -07:00
Jacob Segal
c8371d6089 Add functions for mapping node locations to an id
These locations differ from either local ids (like `5`) or execution ids
(like `3:4:5`) in that all nodes which are 'linked' such that changes to
one node will apply changes to other nodes will map to the same id. This
is specifically relevant for multiple instances of the same subgraph.

These IDs will actually get used in a followup commit
2025-07-06 20:41:58 -07:00
Jacob Segal
a70a81c9ae Address PR feedback 2025-07-03 14:03:11 -07:00
Jacob Segal
aa5fa81824 Update the frontend to support async nodes. 2025-07-02 12:16:17 -07:00
157 changed files with 4818 additions and 11703 deletions

27
.github/workflows/danger.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Danger PR Review
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
danger:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Run Danger
run: npx danger ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,154 +0,0 @@
name: PR Checks
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check-changes.outputs.should_run }}
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure base branch is available
run: |
# Fetch the specific base commit to ensure it's available for git diff
git fetch origin ${{ github.event.pull_request.base.sha }}
- name: Check if significant changes exist
id: check-changes
run: |
# Get list of changed files
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
# Filter for src/ files
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
if [ -z "$SRC_FILES" ]; then
echo "No src/ files changed"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Count lines changed in src files
TOTAL_LINES=0
for file in $SRC_FILES; do
if [ -f "$file" ]; then
# Count added lines (non-empty)
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
# Count removed lines (non-empty)
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
fi
done
echo "Total lines changed in src/: $TOTAL_LINES"
if [ $TOTAL_LINES -gt 3 ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
fi
- name: Check browser test coverage
id: check-coverage
if: steps.check-changes.outputs.should_run == 'true'
run: |
# Check if browser tests were updated
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
if [ -n "$BROWSER_TEST_CHANGES" ]; then
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
else
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for screen recording
id: check-recording
if: steps.check-changes.outputs.should_run == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Check PR body for screen recording
# Check for GitHub user attachments or YouTube links
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
echo "has_recording=true" >> "$GITHUB_OUTPUT"
else
echo "has_recording=false" >> "$GITHUB_OUTPUT"
fi
- name: Final check and create results
id: final-check
if: always()
run: |
# Initialize results
WARNINGS_JSON=""
# Only run checks if should_run is true
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
# Check browser test coverage
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
fi
# Check screen recording
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
fi
fi
# Create results JSON
if [ -n "$WARNINGS_JSON" ]; then
# Create JSON with warnings
cat > pr-check-results.json << EOF
{
"fails": [],
"warnings": [$WARNINGS_JSON],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
else
# Create JSON with success
cat > pr-check-results.json << 'EOF'
{
"fails": [],
"warnings": [],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
fi
# Write PR metadata
echo "${{ github.event.pull_request.number }}" > pr-number.txt
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: pr-check-results-${{ github.run_id }}
path: |
pr-check-results.json
pr-number.txt
pr-sha.txt
retention-days: 1

View File

@@ -1,149 +0,0 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
permissions:
pull-requests: write
issues: write
statuses: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: pr-check-results-${{ github.event.workflow_run.id }}
path: /tmp/pr-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Post results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Helper function to safely read files
function safeReadFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8').trim();
} catch (e) {
console.error(`Error reading ${filePath}:`, e);
return null;
}
}
// Read artifact files
const artifactDir = '/tmp/pr-artifacts';
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
// Validate PR number
if (!prNumber || isNaN(parseInt(prNumber))) {
throw new Error('Invalid or missing PR number');
}
// Parse and validate results
let results;
try {
results = JSON.parse(resultsJson || '{}');
} catch (e) {
console.error('Failed to parse check results:', e);
// Post error comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
});
return;
}
// Format check messages
const messages = [];
if (results.fails && results.fails.length > 0) {
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
}
if (results.warnings && results.warnings.length > 0) {
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
}
if (results.messages && results.messages.length > 0) {
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
}
if (results.markdowns && results.markdowns.length > 0) {
messages.push(...results.markdowns.map(m => m.message));
}
// Find existing bot comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber)
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- pr-checks-comment -->')
);
// Post comment if there are any messages
if (messages.length > 0) {
const body = messages.join('\n\n');
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: commentBody
});
}
} else {
// No messages - delete existing comment if present
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id
});
}
}
// Set commit status based on failures
if (prSha) {
const hasFailures = results.fails && results.fails.length > 0;
const hasWarnings = results.warnings && results.warnings.length > 0;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: prSha,
state: hasFailures ? 'failure' : 'success',
context: 'pr-checks',
description: hasFailures
? `${results.fails.length} check(s) failed`
: hasWarnings
? `${results.warnings.length} warning(s)`
: 'All checks passed'
});
}

View File

@@ -88,7 +88,7 @@ jobs:
- name: Start ComfyUI server
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
python main.py --cpu --multi-user --cache-none --front-end-root ../ComfyUI_frontend/dist &
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
@@ -97,7 +97,7 @@ jobs:
working-directory: ComfyUI_frontend
- name: Run Playwright tests (${{ matrix.browser }})
run: npx playwright test --project=${{ matrix.browser }}
run: npx playwright test --project=${{ matrix.browser }} --workers=1
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4

View File

@@ -2,133 +2,82 @@
This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project.
## Prerequisites
## WARNING
**CRITICAL**: Start ComfyUI backend with `--multi-user` flag:
```bash
python main.py --multi-user
```
Without this flag, parallel tests will conflict and fail randomly.
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
## Setup
### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
```
### Environment Configuration
### Environment Variables
Ensure the environment variables in `.env` are set correctly according to your setup.
Create `.env` from the template:
The `.env` file will not exist until you create it yourself.
A template with helpful information can be found in `.env_example`.
### Running ComfyUI Backend
When running browser tests, ComfyUI must be started with the `--cache-none` argument to disable execution caching:
```bash
cp .env_example .env
python main.py --cache-none
```
Key settings for debugging:
```bash
# Remove Vue dev overlay that blocks UI elements
DISABLE_VUE_PLUGINS=true
# Test against dev server (recommended) or backend directly
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
# Path to ComfyUI for backing up user data/settings before tests
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
```
### Common Setup Issues
**Most tests require the new menu system** - Add to your test:
```typescript
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
```
### Multiple Tests
If you are running Playwright tests in parallel or running the same test multiple times, the flag `--multi-user` must be added to the main ComfyUI process.
### Release API Mocking
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
To test with real release data, you can disable mocking:
```typescript
await comfyPage.setup({ mockReleases: false })
await comfyPage.setup({ mockReleases: false });
```
For tests that specifically need to test release functionality, see the example in `tests/releaseNotifications.spec.ts`.
## Running Tests
**Always use UI mode for development:**
There are multiple ways to run the tests:
```bash
npx playwright test --ui
```
1. **Headless mode with report generation:**
```bash
npx playwright test
```
This runs all tests without a visible browser and generates a comprehensive test report.
UI mode features:
2. **UI mode for interactive testing:**
```bash
npx playwright test --ui
```
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
- **Locator picker**: Click the target icon, then click any element to get the exact locator code to use in your test. The code appears in the _Locator_ tab.
- **Step debugging**: Step through your test line-by-line by clicking _Source_ tab
- **Time travel**: In the _Actions_ tab/panel, click any step to see the browser state at that moment
- **Console/Network Tabs**: View logs and API calls at each step
- **Attachments Tab**: View all snapshots with expected and actual images
![Playwright UI Mode](https://github.com/user-attachments/assets/6a1ebef0-90eb-4157-8694-f5ee94d03755)
![Playwright UI Mode](https://github.com/user-attachments/assets/c158c93f-b39a-44c5-a1a1-e0cc975ee9f2)
For CI or headless testing:
```bash
npx playwright test # Run all tests
npx playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
For debugging, you can try adjusting these settings in `playwright.config.ts`:
```typescript
export default defineConfig({
// VERY HELPFUL: Skip screenshot tests locally
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
timeout: 30000, // Longer timeout for breakpoints
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
})
```
3. **Running specific tests:**
```bash
npx playwright test widget.spec.ts
```
## Test Structure
Browser tests in this project follow a specific organization pattern:
- **Fixtures**: Located in `fixtures/` - These provide test setup and utilities
- `ComfyPage.ts` - The main fixture for interacting with ComfyUI
- `ComfyMouse.ts` - Utility for mouse interactions with the canvas
- Components fixtures in `fixtures/components/` - Page object models for UI components
- **Tests**: Located in `tests/` - The actual test specifications
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
@@ -143,18 +92,18 @@ When writing new tests, follow these patterns:
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage';
test.describe('Feature Name', () => {
// Set up test environment if needed
test.beforeEach(async ({ comfyPage }) => {
// Common setup
})
});
test('should do something specific', async ({ comfyPage }) => {
// Test implementation
})
})
});
});
```
### Leverage Existing Fixtures and Helpers
@@ -176,102 +125,66 @@ Most common testing needs are already addressed by these helpers, which will mak
1. **Focus elements explicitly**:
Canvas-based elements often need explicit focus before interaction:
```typescript
// Click the canvas first to focus it before pressing keys
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('a')
await comfyPage.canvas.click();
await comfyPage.page.keyboard.press('a');
```
2. **Mark canvas as dirty if needed**:
Some interactions need explicit canvas updates:
```typescript
// After programmatically changing node state, mark canvas dirty
await comfyPage.page.evaluate(() => {
window['app'].graph.setDirtyCanvas(true, true)
})
window['app'].graph.setDirtyCanvas(true, true);
});
```
3. **Use node references over coordinates**:
3. **Use node references over coordinates**:
Node references from `fixtures/utils/litegraphUtils.ts` provide stable ways to interact with nodes:
```typescript
// Prefer this:
const node = await comfyPage.getNodeRefsByType('LoadImage')[0]
await node.click('title')
const node = await comfyPage.getNodeRefsByType('LoadImage')[0];
await node.click('title');
// Over this:
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.canvas.click({ position: { x: 100, y: 100 } });
```
4. **Wait for canvas to render after UI interactions**:
```typescript
await comfyPage.nextFrame()
await comfyPage.nextFrame();
```
5. **Clean up persistent server state**:
While most state is reset between tests, anything stored on the server persists:
```typescript
// Reset settings that affect other tests (these are stored on server)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None')
await comfyPage.setSetting('Comfy.ColorPalette', 'dark');
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None');
// Clean up uploaded files if needed
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`)
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`);
```
6. **Prefer functional assertions over screenshots**:
Use screenshots only when visual verification is necessary:
```typescript
// Prefer this:
expect(await node.isPinned()).toBe(true)
expect(await node.getProperty('title')).toBe('Expected Title')
expect(await node.isPinned()).toBe(true);
expect(await node.getProperty('title')).toBe('Expected Title');
// Over this - only use when needed:
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
await expect(comfyPage.canvas).toHaveScreenshot('state.png');
```
7. **Use minimal test workflows**:
When creating test workflows, keep them as minimal as possible:
```typescript
// Include only the components needed for the test
await comfyPage.loadWorkflow('single_ksampler')
await comfyPage.loadWorkflow('single_ksampler');
```
8. **Debug helpers for visual debugging** (remove before committing):
ComfyPage includes temporary debug methods for troubleshooting:
```typescript
test('debug failing interaction', async ({ comfyPage }, testInfo) => {
// Add visual markers to see click positions
await comfyPage.debugAddMarker({ x: 100, y: 200 })
// Attach screenshot with markers to test report
await comfyPage.debugAttachScreenshot(testInfo, 'node-positions', {
element: 'canvas',
markers: [{ position: { x: 100, y: 200 } }]
})
// Show canvas overlay for easier debugging
await comfyPage.debugShowCanvasOverlay()
// Remember to remove debug code before committing!
})
```
Available debug methods:
- `debugAddMarker(position)` - Red circle at position
- `debugAttachScreenshot(testInfo, name)` - Attach to test report
- `debugShowCanvasOverlay()` - Show canvas as overlay
- `debugGetCanvasDataURL()` - Get canvas as base64
## Common Patterns and Utilities
### Page Object Pattern
@@ -285,7 +198,7 @@ test('Can toggle boolean widget', async ({ comfyPage }) => {
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await widget.click()
})
});
```
### Node References
@@ -325,8 +238,8 @@ Canvas operations use special helpers to ensure proper timing:
```typescript
// Using ComfyMouse for drag and drop
await comfyMouse.dragAndDrop(
{ x: 100, y: 100 }, // From
{ x: 200, y: 200 } // To
{ x: 100, y: 100 }, // From
{ x: 200, y: 200 } // To
)
// Standard ComfyPage helpers
@@ -368,52 +281,21 @@ await expect(node).toBeCollapsed()
- **Screenshots vary**: Ensure your OS and browser match the reference environment (Linux)
- **Async / await**: Race conditions are a very common cause of test flakiness
## Screenshot Testing
## Screenshot Expectations
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
- **Do not commit local screenshot expectations** to the repository
- **DO NOT commit local screenshot expectations** to the repository
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment
- While developing, you can generate local screenshots for your tests, but these will differ from CI-generated ones
### Working with Screenshots Locally
To set new test expectations for PR:
Option 1 - Skip screenshot tests (add to `playwright.config.ts`):
1. Write your test with screenshot assertions using `toHaveScreenshot(filename)`
2. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch
3. Add the `New Browser Test Expectation` tag to your pull request
4. The GitHub CI will automatically generate and commit the reference screenshots
```typescript
export default defineConfig({
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
})
```
This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering.
Option 2 - Generate local baselines for comparison:
```bash
npx playwright test --update-snapshots
```
### Getting Test Artifacts from GitHub Actions
When tests fail in CI, you can download screenshots and traces:
1. Go to the failed workflow run in GitHub Actions
2. Scroll to "Artifacts" section at the bottom
3. Download `playwright-report` or `test-results`
4. Extract and open the HTML report locally
5. View actual vs expected screenshots and execution traces
### Creating New Screenshot Baselines
For PRs from `Comfy-Org/ComfyUI_frontend` branches:
1. Write test with `toHaveScreenshot('filename.png')`
2. Create PR and add `New Browser Test Expectation` label
3. CI will generate and commit the Linux baseline screenshots
> **Note:** Fork PRs cannot auto-commit screenshots. A maintainer will need to commit the screenshots manually for you (don't worry, they'll do it).
## Resources
- [Playwright UI Mode](https://playwright.dev/docs/test-ui-mode) - Interactive test debugging
- [Playwright Debugging Guide](https://playwright.dev/docs/debug)
- [act](https://github.com/nektos/act) - Run GitHub Actions locally for CI debugging
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.

View File

@@ -1,244 +0,0 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [
627.5973510742188,
423.0972900390625
],
"size": [
144.15234375,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
347.90441582814213,
417.3822440655296,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
892.5973510742188,
416.0972900390625,
120,
60
]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
1
],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
2
],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
554.8743286132812,
100.95539093017578
],
"size": [
270,
262
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [
685.1265869140625,
439.1734619140625
],
"size": [
140,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [
58.7671207025881,
137.7124650620126
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -0,0 +1,293 @@
{
"id": "test-subgraph-workflow",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0 },
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 10,
"type": "test-subgraph-1",
"pos": [400, 200],
"size": [200, 80],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "IMAGE", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [9] }
],
"title": "Test Subgraph",
"properties": {},
"widgets_values": []
},
{
"id": 11,
"type": "SaveImage",
"pos": [700, 200],
"size": [315, 270],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 9 }
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [
[1, 1, 0, 10, 0, "IMAGE"],
[9, 10, 0, 11, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "test-subgraph-1",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 7,
"lastLinkId": 6,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [-154, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [800, 200, 120, 60]
},
"inputs": [
{
"id": "input-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"localized_name": "IMAGE",
"pos": {
"0": -134,
"1": 220
}
}
],
"outputs": [
{
"id": "output-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [5],
"localized_name": "IMAGE",
"pos": {
"0": 820,
"1": 220
}
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "TestSleep",
"pos": [100, 200],
"size": [210, 86],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.0]
},
{
"id": 5,
"type": "test-subgraph-2",
"pos": [350, 200],
"size": [200, 80],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "IMAGE", "type": "IMAGE", "link": 2 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [5] }
],
"title": "Nested Test Subgraph",
"properties": {},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": 3,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 5,
"origin_id": 5,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
},
{
"id": "test-subgraph-2",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 7,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Nested Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [-154, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [600, 200, 120, 60]
},
"inputs": [
{
"id": "input-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"localized_name": "IMAGE",
"pos": {
"0": -134,
"1": 220
}
}
],
"outputs": [
{
"id": "output-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [4],
"localized_name": "IMAGE",
"pos": {
"0": 620,
"1": 220
}
}
],
"widgets": [],
"nodes": [
{
"id": 6,
"type": "TestSleep",
"pos": [100, 150],
"size": [210, 86],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.0]
},
{
"id": 7,
"type": "TestAsyncProgressNode",
"pos": [350, 150],
"size": [210, 126],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 2 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
"widgets_values": [3.0, 10]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": 6,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 4,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,139 @@
{
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [26, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [1, 2, 3], "slot_index": 0 },
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "TestSleep",
"pos": [400, 100],
"size": [210, 86],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.0]
},
{
"id": 3,
"type": "TestSleep",
"pos": [400, 250],
"size": [210, 86],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 2 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [5], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.5]
},
{
"id": 4,
"type": "TestAsyncProgressNode",
"pos": [400, 400],
"size": [210, 126],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 3 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [6], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
"widgets_values": [3.0, 10]
},
{
"id": 5,
"type": "TestVariadicAverage",
"pos": [700, 200],
"size": [210, 106],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "input1", "type": "IMAGE", "link": 4 },
{ "name": "input2", "type": "IMAGE", "link": 5 },
{ "name": "input3", "type": "IMAGE", "link": 6 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [7], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestVariadicAverage" }
},
{
"id": 6,
"type": "SaveImage",
"pos": [1000, 200],
"size": [315, 270],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 7 }
],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [700, 400],
"size": [210, 54],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null }
],
"outputs": [
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [], "slot_index": 0 }
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": ["test"]
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 1, 0, 3, 0, "IMAGE"],
[3, 1, 0, 4, 0, "IMAGE"],
[4, 2, 0, 5, 0, "IMAGE"],
[5, 3, 0, 5, 1, "IMAGE"],
[6, 4, 0, 5, 2, "IMAGE"],
[7, 5, 0, 6, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,193 @@
{
"last_node_id": 12,
"last_link_id": 13,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [26, 474],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [1, 10], "slot_index": 0 },
{ "name": "CLIP", "type": "CLIP", "links": [2, 3], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8, 11], "slot_index": 2 }
],
"properties": {},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [415, 186],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 2 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [4, 12],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [413, 389],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [5, 13],
"slot_index": 0
}
],
"properties": {},
"widgets_values": ["text, watermark"]
},
{
"id": 4,
"type": "EmptyLatentImage",
"pos": [473, 609],
"size": [315, 106],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [6, 14], "slot_index": 0 }],
"properties": {},
"widgets_values": [512, 512, 1]
},
{
"id": 5,
"type": "KSampler",
"pos": [863, 186],
"size": [315, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 1 },
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
{ "name": "negative", "type": "CONDITIONING", "link": 5 },
{ "name": "latent_image", "type": "LATENT", "link": 6 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
"properties": {},
"widgets_values": [156680208700286, true, 2, 8, "euler", "normal", 1]
},
{
"id": 6,
"type": "VAEDecode",
"pos": [1209, 188],
"size": [210, 46],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 7 },
{ "name": "vae", "type": "VAE", "link": 8 }
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
"properties": {}
},
{
"id": 7,
"type": "SaveImage",
"pos": [1451, 189],
"size": [210, 26],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 8,
"type": "KSampler",
"pos": [863, 486],
"size": [315, 262],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 10 },
{ "name": "positive", "type": "CONDITIONING", "link": 12 },
{ "name": "negative", "type": "CONDITIONING", "link": 13 },
{ "name": "latent_image", "type": "LATENT", "link": 14 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [15], "slot_index": 0 }],
"properties": {},
"widgets_values": [156680208700287, true, 3, 8, "euler", "normal", 1]
},
{
"id": 9,
"type": "VAEDecode",
"pos": [1209, 488],
"size": [210, 46],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 15 },
{ "name": "vae", "type": "VAE", "link": 11 }
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [16], "slot_index": 0 }],
"properties": {}
},
{
"id": 10,
"type": "SaveImage",
"pos": [1451, 489],
"size": [210, 26],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 16 }],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [
[1, 1, 0, 5, 0, "MODEL"],
[2, 1, 1, 2, 0, "CLIP"],
[3, 1, 1, 3, 0, "CLIP"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
[10, 1, 0, 8, 0, "MODEL"],
[11, 1, 2, 9, 1, "VAE"],
[12, 2, 0, 8, 1, "CONDITIONING"],
[13, 3, 0, 8, 2, "CONDITIONING"],
[14, 4, 0, 8, 3, "LATENT"],
[15, 8, 0, 9, 0, "LATENT"],
[16, 9, 0, 10, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,412 +0,0 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
791.59912109375,
386.13336181640625
],
"size": [
210,
202
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {},
"widgets_values": [
"",
""
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 12,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
160
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
128.6640625,
60
]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
},
{
"id": "736e5a03-0f7f-4e48-93e4-fd66ea6c30f1",
"name": "text_1",
"type": "STRING",
"linkIds": [
11
],
"pos": {
"0": 581.59912109375,
"1": 419.13336181640625
}
},
{
"id": "b62e7a0b-cc7e-4ca5-a4e1-c81607a13f58",
"name": "model",
"type": "MODEL",
"linkIds": [
13
],
"pos": {
"0": 581.59912109375,
"1": 439.13336181640625
}
},
{
"id": "7a2628da-4879-4f82-a7d3-7b1c00db50a5",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
14
],
"pos": {
"0": 581.59912109375,
"1": 459.13336181640625
}
},
{
"id": "651cf4ad-e8bf-47f6-b181-8f8aeacd6669",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [
15
],
"pos": {
"0": 581.59912109375,
"1": 479.13336181640625
}
},
{
"id": "c41765ea-61ef-4a77-8cc6-74113903078f",
"name": "latent_image",
"type": "LATENT",
"linkIds": [
16
],
"pos": {
"0": 581.59912109375,
"1": 499.13336181640625
}
}
],
"outputs": [
{
"id": "55dd1505-12bd-4cb4-8e75-031a97bb4387",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [
12
],
"pos": {
"0": 1141.59912109375,
"1": 399.13336181640625
}
}
],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 11,
"type": "CLIPTextEncode",
"pos": [
668.755859375,
571.7766723632812
],
"size": [
400,
200
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
12
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 12,
"type": "KSampler",
"pos": [
671.7379760742188,
1.621593713760376
],
"size": [
270,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 16
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 1,
"type": "STRING"
},
{
"id": 12,
"origin_id": 11,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 2,
"target_id": 12,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 3,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 12,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 5,
"target_id": 12,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.9581355200690549,
"offset": [
184.687451089395,
80.38288288288285
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,341 +0,0 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
400,
300
],
"size": [
210,
168
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
},
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
""
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
160
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
120,
40
]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [
11
],
"pos": {
"0": 581.59912109375,
"1": 419.13336181640625
}
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [
12
],
"pos": {
"0": 581.59912109375,
"1": 439.13336181640625
}
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
13
],
"pos": {
"0": 581.59912109375,
"1": 459.13336181640625
}
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [
14
],
"pos": {
"0": 581.59912109375,
"1": 479.13336181640625
}
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [
15
],
"pos": {
"0": 581.59912109375,
"1": 499.13336181640625
}
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 11,
"type": "KSampler",
"pos": [
674.1234741210938,
570.5839233398438
],
"size": [
270,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.9581355200690549,
"offset": [
258.6405769416877,
147.17927927927929
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,153 +0,0 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
791.59912109375,
386.13336181640625
],
"size": [
140,
26
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
120,
40
]
},
"inputs": [
{
"id": "79e69fca-ad12-499b-8d9b-9f1656b85354",
"name": "clip",
"type": "CLIP",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.24.1",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"ds": {
"scale": 0.9581355200690549,
"offset": [
258.6405769416877,
147.17927927927929
]
}
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -21,7 +21,7 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import type { Position, Size } from './types'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import { NodeReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config()
@@ -168,7 +168,7 @@ export class ComfyPage {
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.settingDialog = new SettingDialog(page)
this.confirmDialog = new ConfirmDialog(page)
}
@@ -563,7 +563,6 @@ export class ComfyPage {
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'
}
@@ -777,524 +776,11 @@ export class ComfyPage {
await this.nextFrame()
}
/**
* Clicks on a litegraph context menu item (uses .litemenu-entry selector).
* Use this for canvas/node context menus, not PrimeVue menus.
*/
async clickLitegraphContextMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
await this.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 rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetInputName) => {
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'
)
}
// Get the input node
const inputNode = currentGraph.inputNode
if (!inputNode) {
throw new Error('No input node found in subgraph')
}
// Get available inputs
const inputs = currentGraph.inputs
if (!inputs || inputs.length === 0) {
throw new Error('No input slots found in subgraph')
}
// Filter to specific input if requested
const inputsToTry = targetInputName
? inputs.filter((inp) => inp.name === targetInputName)
: inputs
if (inputsToTry.length === 0) {
throw new Error(
targetInputName
? `Input slot '${targetInputName}' not found`
: 'No input slots available to try'
)
}
// Try right-clicking on each input slot position until one works
for (const input of inputsToTry) {
if (!input.pos) continue
const testX = input.pos[0]
const testY = input.pos[1]
// Create a right-click event at the input slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the input node's right-click handler
if (inputNode.onPointerDown) {
inputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, inputName: input.name, x: testX, y: testY }
}
}
return { success: false }
}, inputName)
if (!foundSlot.success) {
throw new Error(
inputName
? `Could not open context menu for input slot '${inputName}'`
: 'Could not find any input slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
}
/**
* Right-clicks on a subgraph output slot to open the context menu.
* Must be called when inside a subgraph.
*
* Similar to rightClickSubgraphInputSlot 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 rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
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'
)
}
// Get the output node
const outputNode = currentGraph.outputNode
if (!outputNode) {
throw new Error('No output node found in subgraph')
}
// Get available outputs
const outputs = currentGraph.outputs
if (!outputs || outputs.length === 0) {
throw new Error('No output slots found in subgraph')
}
// Filter to specific output if requested
const outputsToTry = targetOutputName
? outputs.filter((out) => out.name === targetOutputName)
: outputs
if (outputsToTry.length === 0) {
throw new Error(
targetOutputName
? `Output slot '${targetOutputName}' not found`
: 'No output slots available to try'
)
}
// Try right-clicking on each output slot position until one works
for (const output of outputsToTry) {
if (!output.pos) continue
const testX = output.pos[0]
const testY = output.pos[1]
// Create a right-click event at the output slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the output node's right-click handler
if (outputNode.onPointerDown) {
outputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, outputName: output.name, x: testX, y: testY }
}
}
return { success: false }
}, outputName)
if (!foundSlot.success) {
throw new Error(
outputName
? `Could not open context menu for output slot '${outputName}'`
: 'Could not find any output slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
}
/**
* Get a reference to a subgraph input slot
*/
async getSubgraphInputSlot(
slotName?: string
): Promise<SubgraphSlotReference> {
return new SubgraphSlotReference('input', slotName || '', this)
}
/**
* Get a reference to a subgraph output slot
*/
async getSubgraphOutputSlot(
slotName?: string
): Promise<SubgraphSlotReference> {
return new SubgraphSlotReference('output', slotName || '', this)
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
*/
async connectToSubgraphInput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetInputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = await this.getSubgraphInputSlot(targetInputName)
const targetPosition = targetInputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
await this.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 connectFromSubgraphInput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceInputName?: string
): Promise<void> {
const sourceSlot = await this.getSubgraphInputSlot(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()
// Debug: Log the positions we're trying to use
console.log('Drag positions:', {
source: sourcePosition,
target: targetPosition
})
await this.dragAndDrop(sourcePosition, targetPosition)
await this.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 connectToSubgraphOutput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetOutputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = await this.getSubgraphOutputSlot(targetOutputName)
const targetPosition = targetOutputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
await this.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 connectFromSubgraphOutput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceOutputName?: string
): Promise<void> {
const sourceSlot = await this.getSubgraphOutputSlot(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.dragAndDrop(sourcePosition, await targetSlot.getPosition())
await this.nextFrame()
}
/**
* Add a visual marker at a position for debugging
*/
async debugAddMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
// Remove existing marker if present
const existing = document.getElementById(markerId)
if (existing) existing.remove()
// Create marker
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
)
}
/**
* Remove debug markers
*/
async debugRemoveMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
/**
* Take a screenshot and attach it to the test report for debugging
* This is a convenience method that combines screenshot capture and test attachment
*
* @param testInfo The Playwright TestInfo object (from test parameters)
* @param name Name for the attachment
* @param options Optional screenshot options (defaults to page screenshot)
*/
async debugAttachScreenshot(
testInfo: any,
name: string,
options?: {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
): Promise<void> {
// Add markers if requested
if (options?.markers) {
for (const marker of options.markers) {
await this.debugAddMarker(marker.position, marker.id)
}
}
// Take screenshot - default to page if not specified
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()
}
// Attach to test report
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
// Clean up markers if we added any
if (options?.markers) {
await this.debugRemoveMarkers()
}
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
/**
* Capture the canvas as a PNG and save it for debugging
*/
async debugSaveCanvasScreenshot(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')
}
// Convert canvas to blob
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
// Create a download link and trigger it
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)
// Wait a bit for the download to process
await this.page.waitForTimeout(500)
}
/**
* Capture canvas as base64 data URL for inspection
*/
async debugGetCanvasDataURL(): 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')
})
}
/**
* Create an overlay div with the canvas image for easier Playwright screenshot
*/
async debugShowCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
// Remove existing overlay if present
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
// Create overlay div
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'
// Create image from canvas
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)
})
}
/**
* Remove the debug canvas overlay
*/
async debugHideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
async clickEmptyLatentNode() {
await this.canvas.click({
position: {

View File

@@ -1,19 +1,15 @@
import { Page } from '@playwright/test'
import { ComfyPage } from '../ComfyPage'
export class SettingDialog {
constructor(
public readonly page: Page,
public readonly comfyPage: ComfyPage
) {}
constructor(public readonly page: Page) {}
get root() {
return this.page.locator('div.settings-container')
}
async open() {
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
const button = this.page.locator('button.comfy-settings-btn:visible')
await button.click()
await this.page.waitForSelector('div.settings-container')
}

View File

@@ -15,6 +15,10 @@ export class Topbar {
.innerText()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
getMenuItem(itemLabel: string): Locator {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
@@ -64,41 +68,31 @@ export class Topbar {
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
}
async openTopbarMenu() {
await this.page.locator('.comfyui-logo-wrapper').click()
const menu = this.page.locator('.comfy-command-menu')
await menu.waitFor({ state: 'visible' })
return menu
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const menu = await this.openTopbarMenu()
const tabName = path[0]
const topLevelMenuItem = this.page.locator(
`.p-menubar-item-label:text-is("${tabName}")`
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
)
const topLevelMenu = menu
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.hover()
await topLevelMenu.click()
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = currentMenu
const menuItem = this.page
.locator(
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
currentMenu = menuItem
if (i === path.length - 1) {
await menuItem.click()
}
}
await currentMenu.click()
}
}

View File

@@ -12,128 +12,6 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => {
}
}
export class SubgraphSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly slotName: string,
readonly comfyPage: ComfyPage
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type, slotName]) => {
const currentGraph = window['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 slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!slots || slots.length === 0) {
throw new Error(`No ${type} slots found in subgraph`)
}
// Find the specific slot or use the first one if no name specified
const slot = slotName
? slots.find((s) => s.name === slotName)
: slots[0]
if (!slot) {
throw new Error(`${type} slot '${slotName}' not found`)
}
if (!slot.pos) {
throw new Error(`${type} slot '${slotName}' has no position`)
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slot.pos[0],
slot.pos[1]
])
return canvasPos
},
[this.type, this.slotName] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getOpenSlotPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type]) => {
const currentGraph = window['app'].canvas.graph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${type} node found in subgraph`)
}
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slotX,
slotY
])
return canvasPos
},
[this.type] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
@@ -143,27 +21,11 @@ export class NodeSlotReference {
async getPosition() {
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'].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)
// Debug logging - convert Float32Arrays to regular arrays for visibility
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{
nodePos: [node.pos[0], node.pos[1]],
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window['app'].canvas.graph.constructor.name
}
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
return convertedPos
},
[this.type, this.node.id, this.index] as const
)
@@ -175,7 +37,7 @@ export 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'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
@@ -188,7 +50,7 @@ export 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'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
@@ -213,7 +75,7 @@ export 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'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
@@ -272,7 +134,7 @@ export class NodeWidgetReference {
const pos = await this.getPosition()
const canvas = this.node.comfyPage.canvas
const canvasPos = (await canvas.boundingBox())!
await this.node.comfyPage.dragAndDrop(
this.node.comfyPage.dragAndDrop(
{
x: canvasPos.x + pos.x,
y: canvasPos.y + pos.y
@@ -304,7 +166,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'].graph.getNodeById(id)
return !!node
}, this.id)
}
@@ -323,7 +185,7 @@ export class NodeReference {
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].canvas.graph.getNodeById(id)
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
@@ -356,7 +218,7 @@ 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'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
@@ -397,8 +259,7 @@ export class NodeReference {
await this.comfyPage.canvas.click({
...options,
position: clickPos,
force: true
position: clickPos
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
@@ -458,18 +319,6 @@ export class NodeReference {
}
return nodes[0]
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(256)
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
if (nodes.length !== 1) {
throw new Error(
`Did not find single subgraph node (found=${nodes.length})`
)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
@@ -478,58 +327,4 @@ export class NodeReference {
this.comfyPage.page.locator('.comfy-group-manage')
)
}
async navigateIntoSubgraph() {
const titleHeight = await this.comfyPage.page.evaluate(() => {
return window['LiteGraph']['NODE_TITLE_HEIGHT']
})
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
// Try multiple positions to avoid DOM widget interference
const clickPositions = [
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
let isInSubgraph = false
let attempts = 0
const maxAttempts = 3
while (!isInSubgraph && attempts < maxAttempts) {
attempts++
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 50, y: 50 },
force: true
})
await this.comfyPage.nextFrame()
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(500)
// 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
}
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

@@ -0,0 +1,507 @@
import { Page } from '@playwright/test'
export interface ExecutionEventTracker {
progressStates: any[]
executionStarted: boolean
executionFinished: boolean
executionError: any | null
executingNodeId: string | null
}
export interface ProgressState {
prompt_id: string
nodes: Record<
string,
{
state: 'running' | 'finished' | 'waiting'
node_id: string
display_node_id: string
prompt_id: string
value?: number
max?: number
}
>
}
export class ExecutionTestHelper {
private testId: string
constructor(private page: Page) {
// Generate unique ID for this test instance to avoid conflicts
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Sets up common event tracking for execution tests
*/
async setupEventTracking(): Promise<void> {
await this.page.evaluate((testId) => {
// Use unique property names for this test instance
const progressKey = `__progressStates_${testId}`
const startedKey = `__executionStarted_${testId}`
const finishedKey = `__executionFinished_${testId}`
const errorKey = `__executionError_${testId}`
const nodeIdKey = `__executingNodeId_${testId}`
window[progressKey] = []
window[startedKey] = false
window[finishedKey] = false
window[errorKey] = null
window[nodeIdKey] = null
const api = window['app'].api
// Store listeners so we can clean them up later
if (!window['__testListeners']) {
window['__testListeners'] = {}
}
// Remove old listeners if they exist
if (window['__testListeners'][testId]) {
const oldListeners = window['__testListeners'][testId]
api.removeEventListener('progress_state', oldListeners.progress)
api.removeEventListener('executing', oldListeners.executing)
api.removeEventListener('execution_error', oldListeners.error)
}
// Create new listeners
const progressListener = (event) => {
window[progressKey].push(event.detail)
}
const executingListener = (event) => {
window[nodeIdKey] = event.detail
if (event.detail !== null) {
window[startedKey] = true
} else {
window[finishedKey] = true
}
}
const errorListener = (event) => {
window[errorKey] = event.detail
}
// Add listeners
api.addEventListener('progress_state', progressListener)
api.addEventListener('executing', executingListener)
api.addEventListener('execution_error', errorListener)
// Store listeners for cleanup
window['__testListeners'][testId] = {
progress: progressListener,
executing: executingListener,
error: errorListener
}
}, this.testId)
}
/**
* Gets the current event tracking state
*/
async getEventState(): Promise<ExecutionEventTracker> {
return await this.page.evaluate(
(testId) => ({
progressStates: window[`__progressStates_${testId}`] || [],
executionStarted: window[`__executionStarted_${testId}`] || false,
executionFinished: window[`__executionFinished_${testId}`] || false,
executionError: window[`__executionError_${testId}`] || null,
executingNodeId: window[`__executingNodeId_${testId}`] || null
}),
this.testId
)
}
/**
* Waits for execution to start
*/
async waitForExecutionStart(timeout: number = 10000): Promise<void> {
await this.page.waitForFunction(
(testId) => window[`__executionStarted_${testId}`] === true,
this.testId,
{ timeout }
)
}
/**
* Waits for execution to finish
*/
async waitForExecutionFinish(timeout: number = 30000): Promise<void> {
await this.page.waitForFunction(
(testId) => window[`__executionFinished_${testId}`] === true,
this.testId,
{ timeout }
)
}
/**
* Waits for a specific number of nodes to be running
*/
async waitForRunningNodes(
count: number,
timeout: number = 10000
): Promise<void> {
await this.page.waitForFunction(
({ expectedCount, testId }) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running'
).length
return runningNodes >= expectedCount
},
{ expectedCount: count, testId: this.testId },
{ timeout }
)
}
/**
* Waits for at least one node to finish
*/
async waitForNodeFinish(timeout: number = 15000): Promise<void> {
await this.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
return Object.values(latestState.nodes).some(
(node: any) => node.state === 'finished'
)
},
this.testId,
{ timeout }
)
}
/**
* Gets the latest progress state
*/
async getLatestProgressState(): Promise<ProgressState | null> {
return await this.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
return states[states.length - 1]
}, this.testId)
}
/**
* Waits for node progress to be applied to the graph
*/
async waitForGraphNodeProgress(
nodeIds: number[],
timeout: number = 5000
): Promise<void> {
await this.page.waitForFunction(
(ids) => {
return ids.some((id) => {
const node = window['app'].graph.getNodeById(id)
return node?.progress !== undefined && node.progress >= 0
})
},
nodeIds,
{ timeout }
)
}
/**
* Gets node progress from the graph
*/
async getGraphNodeProgress(nodeId: number): Promise<number | undefined> {
return await this.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return node?.progress
}, nodeId)
}
/**
* Checks if execution had errors
*/
async hasExecutionError(): Promise<boolean> {
const error = await this.page.evaluate(
(testId) => window[`__executionError_${testId}`],
this.testId
)
return error !== null
}
/**
* Gets execution error details
*/
async getExecutionError(): Promise<any> {
return await this.page.evaluate(
(testId) => window[`__executionError_${testId}`],
this.testId
)
}
/**
* Cleanup event listeners when test is done
*/
async cleanup(): Promise<void> {
await this.page.evaluate((testId) => {
if (window['__testListeners'] && window['__testListeners'][testId]) {
const api = window['app'].api
const listeners = window['__testListeners'][testId]
api.removeEventListener('progress_state', listeners.progress)
api.removeEventListener('executing', listeners.executing)
api.removeEventListener('execution_error', listeners.error)
delete window['__testListeners'][testId]
}
// Clean up test-specific properties
delete window[`__progressStates_${testId}`]
delete window[`__executionStarted_${testId}`]
delete window[`__executionFinished_${testId}`]
delete window[`__executionError_${testId}`]
delete window[`__executingNodeId_${testId}`]
}, this.testId)
}
/**
* Get the testId for direct window access in evaluate functions
*/
getTestId(): string {
return this.testId
}
}
/**
* Helper for browser title monitoring
*/
export class BrowserTitleMonitor {
constructor(private page: Page) {}
/**
* Waits for title to not show execution state
*/
async waitForIdleTitle(timeout: number = 10000): Promise<void> {
await this.page.waitForFunction(
() => {
const title = document.title
return !title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
},
{ timeout }
)
}
/**
* Waits for title to show execution state
*/
async waitForExecutionTitle(timeout: number = 5000): Promise<void> {
await this.page.waitForFunction(
() => {
const title = document.title
return title.match(/\[\d+%\]/) || title.match(/\[\d+ nodes running\]/)
},
{ timeout }
)
}
/**
* Sets up title change monitoring
*/
async setupTitleMonitoring(): Promise<void> {
await this.page.evaluate(() => {
window['__titleUpdateLog'] = []
window['__lastTitle'] = document.title
window['__titleInterval'] = setInterval(() => {
const newTitle = document.title
if (newTitle !== window['__lastTitle']) {
window['__titleUpdateLog'].push({
time: Date.now(),
title: newTitle,
hasProgress: !!newTitle.match(/\[\d+%\]/),
hasMultiNode: !!newTitle.match(/\[\d+ nodes running\]/)
})
window['__lastTitle'] = newTitle
}
}, 50)
})
}
/**
* Stops title monitoring and returns the log
*/
async stopTitleMonitoring(): Promise<any[]> {
const log = await this.page.evaluate(() => {
if (window['__titleInterval']) {
clearInterval(window['__titleInterval'])
}
return window['__titleUpdateLog'] || []
})
return log
}
}
/**
* Helper for preview event handling
*/
export class PreviewTestHelper {
constructor(private page: Page) {}
/**
* Sets up preview event tracking
*/
async setupPreviewTracking(): Promise<void> {
await this.page.evaluate(() => {
window['__previewEvents'] = []
window['__revokedNodes'] = []
window['__revokedUrls'] = []
// Track preview events
const api = window['app'].api
api.addEventListener('b_preview_with_metadata', (event) => {
window['__previewEvents'].push({
nodeId: event.detail.nodeId,
displayNodeId: event.detail.displayNodeId,
parentNodeId: event.detail.parentNodeId,
realNodeId: event.detail.realNodeId,
promptId: event.detail.promptId
})
})
// Mock revokePreviews to track calls
const originalRevoke = window['app'].revokePreviews
window['app'].revokePreviews = function (nodeId) {
window['__revokedNodes'].push(nodeId)
originalRevoke.call(this, nodeId)
}
// Mock URL.revokeObjectURL to track URL revocations
const originalRevokeURL = URL.revokeObjectURL
URL.revokeObjectURL = (url: string) => {
window['__revokedUrls'].push(url)
originalRevokeURL.call(URL, url)
}
})
}
/**
* Gets tracked preview events
*/
async getPreviewEvents(): Promise<any[]> {
return await this.page.evaluate(() => window['__previewEvents'] || [])
}
/**
* Gets revoked node IDs
*/
async getRevokedNodes(): Promise<string[]> {
return await this.page.evaluate(() => window['__revokedNodes'] || [])
}
/**
* Gets revoked URLs
*/
async getRevokedUrls(): Promise<string[]> {
return await this.page.evaluate(() => window['__revokedUrls'] || [])
}
/**
* Sets fake preview for a node
*/
async setNodePreview(nodeId: string, previewUrl: string): Promise<void> {
await this.page.evaluate(
({ id, url }) => {
window['app'].nodePreviewImages[id] = [url]
},
{ id: nodeId, url: previewUrl }
)
}
/**
* Gets node preview URLs
*/
async getNodePreviews(nodeId: string): Promise<string[] | undefined> {
return await this.page.evaluate(
(id) => window['app'].nodePreviewImages[id],
nodeId
)
}
}
/**
* Helper for checking subgraph execution
*/
export class SubgraphTestHelper {
private testId: string
constructor(private page: Page) {
// Generate unique ID for this test instance
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Sets the test ID to match ExecutionTestHelper
*/
setTestId(testId: string): void {
this.testId = testId
}
/**
* Waits for nested node progress (nodes with ':' in their ID)
*/
async waitForNestedNodeProgress(
minNestingLevel: number = 1,
timeout: number = 15000
): Promise<void> {
await this.page.waitForFunction(
({ minLevel, testId }) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
return states.some((state: any) => {
if (!state.nodes) return false
return Object.keys(state.nodes).some((nodeId) => {
const colonCount = (nodeId.match(/:/g) || []).length
return colonCount >= minLevel
})
})
},
{ minLevel: minNestingLevel, testId: this.testId },
{ timeout }
)
}
/**
* Gets all nested node IDs from progress states
*/
async getNestedNodeIds(): Promise<string[]> {
return await this.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`] || []
const nestedIds = new Set<string>()
states.forEach((state: any) => {
if (state.nodes) {
Object.keys(state.nodes).forEach((nodeId) => {
if (nodeId.includes(':')) {
nestedIds.add(nodeId)
}
})
}
})
return Array.from(nestedIds)
}, this.testId)
}
/**
* Checks if a node has running stroke style
*/
async hasRunningStrokeStyle(nodeId: number): Promise<boolean> {
return await this.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node?.strokeStyles?.['running']) return false
const style = node.strokeStyles['running'].call(node)
return style?.color === '#0f0'
}, nodeId)
}
}

View File

@@ -0,0 +1,316 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
BrowserTitleMonitor,
ExecutionTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Browser Tab Title - Multi-node Execution', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let titleMonitor: BrowserTitleMonitor
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
executionHelper = new ExecutionTestHelper(comfyPage.page)
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
})
test.afterEach(async () => {
// Clean up event listeners to avoid conflicts
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Shows multiple nodes running in tab title', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle()
// Get initial title
const initialTitle = await comfyPage.page.title()
// Title might show execution state if other tests are running
// Just ensure we have a baseline to compare against
// Wait for the UI to be ready
await comfyPage.nextFrame()
// Check if workflow is valid and nodes are available
const workflowStatus = await comfyPage.page.evaluate(() => {
const graph = window['app'].graph
const missingNodeTypes: string[] = []
const nodeCount = graph.nodes.length
// Check for missing node types
graph.nodes.forEach((node: any) => {
if (node.type && !LiteGraph.registered_node_types[node.type]) {
missingNodeTypes.push(node.type)
}
})
return {
nodeCount,
missingNodeTypes,
hasErrors: missingNodeTypes.length > 0
}
})
if (workflowStatus.hasErrors) {
console.log('Missing node types:', workflowStatus.missingNodeTypes)
// Skip test if nodes are missing
test.skip()
return
}
// Set up tracking for progress events and errors
await executionHelper.setupEventTracking()
// Queue the workflow for real execution using the command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait a moment to see if there's an error
await comfyPage.page.waitForTimeout(1000)
// Check for execution errors
if (await executionHelper.hasExecutionError()) {
const error = await executionHelper.getExecutionError()
console.log('Execution error:', error)
}
// Wait for multiple nodes to be running (TestSleep nodes 2, 3 and TestAsyncProgressNode 4)
await executionHelper.waitForRunningNodes(2)
// Check title while we know multiple nodes are running
const testId = executionHelper.getTestId()
const titleDuringExecution = await comfyPage.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
const latestState = states[states.length - 1]
if (!latestState.nodes) return null
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running'
).length
return {
title: document.title,
runningCount: runningNodes
}
}, testId)
// Verify we captured the state with multiple nodes running
expect(titleDuringExecution).not.toBeNull()
expect(titleDuringExecution.runningCount).toBeGreaterThanOrEqual(2)
// The title should show multiple nodes running when we have 2+ nodes executing
if (titleDuringExecution.runningCount >= 2) {
expect(titleDuringExecution.title).toMatch(/\[\d+ nodes running\]/)
}
// Wait for some nodes to finish, leaving only one running
await executionHelper.waitForRunningNodes(1, 15000)
// Wait for title to show single node progress
await comfyPage.page.waitForFunction(
() => {
const title = document.title
return title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
},
{ timeout: 5000 }
)
// Check that title shows single node with progress
const titleWithSingleNode = await comfyPage.page.title()
expect(titleWithSingleNode).toMatch(/\[\d+%\]/)
expect(titleWithSingleNode).not.toMatch(/\[\d+ nodes running\]/)
})
test('Shows progress updates in title during execution', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for the UI to be ready
await comfyPage.nextFrame()
// Set up tracking for progress events and title changes
await executionHelper.setupEventTracking()
await titleMonitor.setupTitleMonitoring()
// Queue the workflow for real execution using the command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for TestAsyncProgressNode (node 4) to start showing progress
// This node reports progress from 0 to 10 with steps of 1
const testId2 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes || !latestState.nodes['4']) return false
const node4 = latestState.nodes['4']
if (node4.state === 'running' && node4.value > 0) {
window['__lastProgress'] = Math.round((node4.value / node4.max) * 100)
return true
}
return false
},
testId2,
{ timeout: 10000 }
)
// Wait for title to show progress percentage
await comfyPage.page.waitForFunction(
() => {
const title = document.title
console.log('Title check 1:', title)
return title.match(/\[\d+%\]/)
},
{ timeout: 5000 }
)
// Check that title shows a progress percentage
const titleWithProgress = await comfyPage.page.title()
expect(titleWithProgress).toMatch(/\[\d+%\]/)
// Wait for progress to update to a different value
const firstProgress = await comfyPage.page.evaluate(
() => window['__lastProgress']
)
const testId3 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
({ initialProgress, testId }) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes || !latestState.nodes['4']) return false
const node4 = latestState.nodes['4']
if (node4.state === 'running') {
const currentProgress = Math.round((node4.value / node4.max) * 100)
window['__lastProgress'] = currentProgress
return currentProgress > initialProgress
}
return false
},
{ initialProgress: firstProgress, testId: testId3 },
{ timeout: 10000 }
)
// Store the first progress for comparison
await comfyPage.page.evaluate((progress) => {
window['__firstProgress'] = progress
}, firstProgress)
// Check the title history to verify we captured progress updates
const finalCheck = await comfyPage.page.evaluate(() => {
const titleLog = window['__titleUpdateLog'] || []
const firstProgress = window['__firstProgress'] || 0
// Find titles with progress information
const titlesWithProgress = titleLog.filter((entry) => entry.hasProgress)
// Check if we saw different progress values or multi-node running state
const progressValues = new Set()
const hadMultiNodeRunning = titleLog.some((entry) =>
entry.title.includes('nodes running')
)
titleLog.forEach((entry) => {
const match = entry.title.match(/\[(\d+)%\]/)
if (match) {
progressValues.add(parseInt(match[1]))
}
})
return {
sawProgressUpdates: titlesWithProgress.length > 0,
uniqueProgressValues: Array.from(progressValues),
hadMultiNodeRunning,
firstProgress,
lastProgress: window['__lastProgress'],
totalTitleUpdates: titleLog.length,
sampleTitles: titleLog.slice(0, 5)
}
})
console.log('Title update check:', JSON.stringify(finalCheck, null, 2))
// Verify that we captured title updates showing execution progress
expect(finalCheck.sawProgressUpdates).toBe(true)
expect(finalCheck.totalTitleUpdates).toBeGreaterThan(0)
// We should have seen either:
// 1. Multiple unique progress values, OR
// 2. Multi-node running state, OR
// 3. Progress different from initial
const sawProgressChange =
finalCheck.uniqueProgressValues.length > 1 ||
finalCheck.hadMultiNodeRunning ||
finalCheck.lastProgress !== firstProgress
expect(sawProgressChange).toBe(true)
// Clean up interval
await titleMonitor.stopTitleMonitoring()
})
test('Clears execution status from title when all nodes finish', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle()
// Wait for the UI to be ready
await comfyPage.nextFrame()
// Set up tracking for events
await executionHelper.setupEventTracking()
// Queue the workflow for real execution using the command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to show progress in title
await titleMonitor.waitForExecutionTitle()
// Verify execution shows in title
const executingTitle = await comfyPage.page.title()
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
// Wait for execution to complete (all nodes finished)
await executionHelper.waitForExecutionFinish()
// Give a moment for title to update after execution completes
await comfyPage.page.waitForTimeout(500)
// Wait for title to clear execution status
await titleMonitor.waitForIdleTitle()
// Check that execution status is cleared
const finishedTitle = await comfyPage.page.title()
expect(finishedTitle).toContain('ComfyUI')
expect(finishedTitle).not.toMatch(/\[\d+%\]/) // No percentage
expect(finishedTitle).not.toMatch(/\[\d+ nodes running\]/) // No running nodes
expect(finishedTitle).not.toContain('Executing')
})
})

View File

@@ -0,0 +1,85 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
BrowserTitleMonitor,
ExecutionTestHelper
} from '../helpers/ExecutionTestHelper'
test.describe('Browser Tab Title - Multi-node Simple', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let titleMonitor: BrowserTitleMonitor
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
executionHelper = new ExecutionTestHelper(comfyPage.page)
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Title updates based on execution state', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle().catch(() => {
// If timeout, cancel any running execution
return comfyPage.page.keyboard.press('Escape')
})
// Get initial title
const initialTitle = await comfyPage.page.title()
// Title might show execution state if other tests are running
// Just ensure we can detect when it changes
const hasExecutionState =
initialTitle.match(/\[\d+%\]/) ||
initialTitle.match(/\[\d+ nodes running\]/)
// Set up tracking for execution events
await executionHelper.setupEventTracking()
// Start real execution using command instead of button
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for title to update with execution state
await titleMonitor.waitForExecutionTitle()
const executingTitle = await comfyPage.page.title()
// If initial title didn't have execution state, it should be different now
if (!hasExecutionState) {
expect(executingTitle).not.toBe(initialTitle)
}
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
})
test('Can read workflow name from title', async ({ comfyPage }) => {
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle(5000).catch(async () => {
// Cancel any running execution
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.waitForTimeout(1000)
})
// Set a workflow name
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.workflow.activeWorkflow.filename =
'test-workflow'
})
// Wait for title to update
await comfyPage.page.waitForTimeout(100)
const title = await comfyPage.page.title()
expect(title).toContain('test-workflow')
// Title should contain workflow name regardless of execution state
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -768,14 +768,8 @@ test.describe('Viewport settings', () => {
comfyMouse
}) => {
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await comfyPage.nextFrame()
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
@@ -783,12 +777,7 @@ test.describe('Viewport settings', () => {
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
await comfyPage.nextFrame()
const screenshotB = (await comfyPage.canvas.screenshot()).toString('base64')
// Ensure that the screenshots are different due to zoom level
expect(screenshotB).not.toBe(screenshotA)
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
@@ -796,269 +785,11 @@ test.describe('Viewport settings', () => {
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)
})
})
test.describe('Canvas Navigation', () => {
test.describe('Legacy Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
})
test('Left-click drag in empty area should pan canvas', async ({
comfyPage
}) => {
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-left-drag-pan.png'
)
})
test('Middle-click drag should pan canvas', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-middle-drag-pan.png'
)
})
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-wheel-zoom-in.png'
)
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-wheel-zoom-out.png'
)
})
test('Left-click on node should not pan canvas', async ({ comfyPage }) => {
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-click-node-select.png'
)
})
})
test.describe('Standard Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'standard')
})
test('Left-click drag in empty area should select nodes', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
},
{
x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset,
y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset
}
)
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(clipNodes.length)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-left-drag-select.png'
)
})
test('Middle-click drag should pan canvas', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-middle-drag-pan.png'
)
})
test('Ctrl + mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-ctrl-wheel-zoom-in.png'
)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-ctrl-wheel-zoom-out.png'
)
})
test('Left-click on node should select node (not start selection box)', async ({
comfyPage
}) => {
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-click-node-select.png'
)
})
test('Space + left-click drag should pan canvas', async ({ comfyPage }) => {
// Click canvas to focus it
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.down('Space')
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'
)
})
test('Space key overrides default left-click behavior', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const offset = 64
await comfyPage.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
},
{
x: clipNode1Pos.x + offset,
y: clipNode1Pos.y + offset
}
)
const selectedCountAfterDrag =
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterDrag).toBeGreaterThan(0)
await comfyPage.clickEmptySpace()
const selectedCountAfterClear =
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterClear).toBe(0)
await comfyPage.page.keyboard.down('Space')
await comfyPage.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
},
{
x: clipNode1Pos.x + offset,
y: clipNode1Pos.y + offset
}
)
await comfyPage.page.keyboard.up('Space')
const selectedCountAfterSpaceDrag =
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterSpaceDrag).toBe(0)
})
})
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-right.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, -240)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-left.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-center.png'
)
})
test.describe('Edge Cases', () => {
test('Multiple modifier keys work correctly in legacy mode', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.page.keyboard.up('Shift')
await comfyPage.page.keyboard.up('Alt')
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-alt-shift-drag.png'
)
})
test('Cursor changes appropriately in different modes', async ({
comfyPage
}) => {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
)
})
}
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
})
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -15,8 +15,7 @@ test.describe('Load Workflow in Media', () => {
'workflow.mp4',
'workflow.mov',
'workflow.m4v',
'workflow.svg',
'workflow.avif'
'workflow.svg'
]
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -63,7 +63,7 @@ test.describe('Menu', () => {
test('@mobile Items fully visible on mobile screen width', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.openTopbarMenu()
await comfyPage.menu.topbar.openSubmenuMobile()
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
@@ -74,9 +74,8 @@ test.describe('Menu', () => {
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.hover()
await workflowMenuItem.click()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})

View File

@@ -1,77 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Minimap', () => {
test.beforeEach(async ({ comfyPage }) => {
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 }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await expect(toggleButton).not.toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
})
})

View File

@@ -0,0 +1,385 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
PreviewTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Multi-node Execution Progress', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let previewHelper: PreviewTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
previewHelper = new PreviewTestHelper(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Can track progress of multiple async nodes executing in parallel', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Get references to the async nodes
const sleepNode1 = await comfyPage.getNodeRefById(2)
const sleepNode2 = await comfyPage.getNodeRefById(3)
const progressNode = await comfyPage.getNodeRefById(4)
// Verify nodes are present
expect(sleepNode1).toBeDefined()
expect(sleepNode2).toBeDefined()
expect(progressNode).toBeDefined()
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start execution
await comfyPage.queueButton.click()
// Wait for all three nodes (2, 3, 4) to show progress from real execution
const testId = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
const node2 = latestState.nodes['2']
const node3 = latestState.nodes['3']
const node4 = latestState.nodes['4']
// Check that all nodes have started executing
return (
node2 &&
node2.state === 'running' &&
node3 &&
node3.state === 'running' &&
node4 &&
node4.state === 'running'
)
},
testId,
{ timeout: 10000 }
)
// Wait for progress to be applied to all nodes in the graph
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
// Check that all nodes show progress
const nodeProgress1 = await sleepNode1.getProperty('progress')
const nodeProgress2 = await sleepNode2.getProperty('progress')
const nodeProgress3 = await progressNode.getProperty('progress')
// Progress values should now be defined (exact values depend on timing)
expect(nodeProgress1).toBeDefined()
expect(nodeProgress2).toBeDefined()
expect(nodeProgress3).toBeDefined()
expect(nodeProgress1).toBeGreaterThanOrEqual(0)
expect(nodeProgress1).toBeLessThanOrEqual(1)
expect(nodeProgress2).toBeGreaterThanOrEqual(0)
expect(nodeProgress2).toBeLessThanOrEqual(1)
expect(nodeProgress3).toBeGreaterThanOrEqual(0)
expect(nodeProgress3).toBeLessThanOrEqual(1)
// Wait for at least one node to finish
await executionHelper.waitForNodeFinish()
// Wait for the finished node's progress to be cleared
const testId2 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
// Find which nodes are finished
const finishedNodeIds = Object.entries(latestState.nodes)
.filter(([_, node]: [string, any]) => node.state === 'finished')
.map(([id, _]) => id)
// Check that finished nodes have no progress in the graph
return finishedNodeIds.some((id) => {
const node = window['app'].graph.getNodeById(parseInt(id))
return node && node.progress === undefined
})
},
testId2,
{ timeout: 5000 }
)
// Get current state of nodes
const testId3 = executionHelper.getTestId()
const currentState = await comfyPage.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
const latestState = states[states.length - 1]
const graphNodes = {
'2': window['app'].graph.getNodeById(2),
'3': window['app'].graph.getNodeById(3),
'4': window['app'].graph.getNodeById(4)
}
return {
stateNodes: latestState.nodes,
graphProgress: {
'2': graphNodes['2']?.progress,
'3': graphNodes['3']?.progress,
'4': graphNodes['4']?.progress
}
}
}, testId3)
// Verify that finished nodes have no progress, running nodes have progress
if (currentState && currentState.stateNodes) {
Object.entries(currentState.stateNodes).forEach(
([nodeId, nodeState]: [string, any]) => {
const graphProgress = currentState.graphProgress[nodeId]
if (nodeState.state === 'finished') {
expect(graphProgress).toBeUndefined()
} else if (nodeState.state === 'running') {
expect(graphProgress).toBeDefined()
expect(graphProgress).toBeGreaterThanOrEqual(0)
expect(graphProgress).toBeLessThanOrEqual(1)
}
}
)
}
// Clean up by canceling execution
})
test('Updates visual state for multiple executing nodes', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for the graph to be properly initialized
await comfyPage.page.waitForFunction(
() => {
return window['app']?.graph?.nodes?.length > 0
},
{ timeout: 5000 }
)
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start execution
await comfyPage.queueButton.click()
// Wait for multiple nodes to start executing
await executionHelper.waitForRunningNodes(2)
// Wait for the progress to be applied to nodes
await executionHelper.waitForGraphNodeProgress([2, 3])
// Verify that nodes have progress set (indicates they are executing)
const nodeStates = await comfyPage.page.evaluate(() => {
const node2 = window['app'].graph.getNodeById(2)
const node3 = window['app'].graph.getNodeById(3)
return {
node2Progress: node2?.progress,
node3Progress: node3?.progress,
// Check if any nodes are marked as running by having progress
hasRunningNodes:
(node2?.progress !== undefined && node2?.progress >= 0) ||
(node3?.progress !== undefined && node3?.progress >= 0)
}
})
expect(nodeStates.node2Progress).toBeDefined()
expect(nodeStates.node3Progress).toBeDefined()
expect(nodeStates.hasRunningNodes).toBe(true)
// Wait for at least one node to finish
await executionHelper.waitForNodeFinish()
// Wait for progress updates to reflect the finished state
const testId4 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
// Find nodes by their state
const finishedNodes = Object.entries(latestState.nodes)
.filter(([_, node]: [string, any]) => node.state === 'finished')
.map(([id, _]) => parseInt(id))
const runningNodes = Object.entries(latestState.nodes)
.filter(([_, node]: [string, any]) => node.state === 'running')
.map(([id, _]) => parseInt(id))
// Check graph nodes match the state
const allFinishedCorrect = finishedNodes.every((id) => {
const node = window['app'].graph.getNodeById(id)
return node && node.progress === undefined
})
const allRunningCorrect = runningNodes.every((id) => {
const node = window['app'].graph.getNodeById(id)
return node && node.progress !== undefined && node.progress >= 0
})
return (
allFinishedCorrect && allRunningCorrect && finishedNodes.length > 0
)
},
testId4,
{ timeout: 5000 }
)
// Verify the final node states
const testId5 = executionHelper.getTestId()
const finalNodeStates = await comfyPage.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
const latestState = states[states.length - 1]
const node2 = window['app'].graph.getNodeById(2)
const node3 = window['app'].graph.getNodeById(3)
return {
node2State: latestState.nodes['2']?.state,
node3State: latestState.nodes['3']?.state,
node2Progress: node2?.progress,
node3Progress: node3?.progress
}
}, testId5)
// Verify finished nodes have no progress, running nodes have progress
if (finalNodeStates) {
if (finalNodeStates.node2State === 'finished') {
expect(finalNodeStates.node2Progress).toBeUndefined()
} else if (finalNodeStates.node2State === 'running') {
expect(finalNodeStates.node2Progress).toBeDefined()
}
if (finalNodeStates.node3State === 'finished') {
expect(finalNodeStates.node3Progress).toBeUndefined()
} else if (finalNodeStates.node3State === 'running') {
expect(finalNodeStates.node3Progress).toBeDefined()
}
}
})
test('Clears previews when nodes start executing', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Initialize tracking for revoked previews
await comfyPage.page.evaluate(() => {
window['__revokedNodes'] = []
})
// Set up some fake previews
await comfyPage.page.evaluate(() => {
window['app'].nodePreviewImages['2'] = ['fake-preview-url-1']
window['app'].nodePreviewImages['3'] = ['fake-preview-url-2']
})
// Verify previews exist
const previewsBefore = await comfyPage.page.evaluate(() => {
return {
node2: window['app'].nodePreviewImages['2'],
node3: window['app'].nodePreviewImages['3']
}
})
expect(previewsBefore.node2).toEqual(['fake-preview-url-1'])
expect(previewsBefore.node3).toEqual(['fake-preview-url-2'])
// Mock revokePreviews to track calls and set up event listeners
await previewHelper.setupPreviewTracking()
await executionHelper.setupEventTracking()
// Start real execution using command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real execution to trigger progress events that clear previews
const testId6 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
// Check if we have progress for nodes 2 and 3
const hasNode2Progress = states.some(
(state: any) =>
state.nodes &&
state.nodes['2'] &&
state.nodes['2'].state === 'running'
)
const hasNode3Progress = states.some(
(state: any) =>
state.nodes &&
state.nodes['3'] &&
state.nodes['3'].state === 'running'
)
return hasNode2Progress && hasNode3Progress
},
testId6,
{ timeout: 10000 }
)
// Wait for the event to be processed and previews to be revoked
await comfyPage.page.waitForFunction(
() => {
const revokedNodes = window['__revokedNodes']
const node2PreviewCleared =
window['app'].nodePreviewImages['2'] === undefined
const node3PreviewCleared =
window['app'].nodePreviewImages['3'] === undefined
return (
revokedNodes.includes('2') &&
revokedNodes.includes('3') &&
node2PreviewCleared &&
node3PreviewCleared
)
},
{ timeout: 5000 }
)
// Check that revokePreviews was called for both nodes
const revokedNodes = await previewHelper.getRevokedNodes()
expect(revokedNodes).toContain('2')
expect(revokedNodes).toContain('3')
// Check that previews were cleared
const previewsAfter = await comfyPage.page.evaluate(() => {
return {
node2: window['app'].nodePreviewImages['2'],
node3: window['app'].nodePreviewImages['3']
}
})
expect(previewsAfter.node2).toBeUndefined()
expect(previewsAfter.node3).toBeUndefined()
})
})

View File

@@ -0,0 +1,168 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
PreviewTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Multi-node Execution Progress - Simple', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let previewHelper: PreviewTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
previewHelper = new PreviewTestHelper(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Can dispatch and receive progress_state events', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Set up event tracking
await executionHelper.setupEventTracking()
// Start real execution
await comfyPage.queueButton.click()
// Wait for real progress_state events from backend
const testId = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
return latestState.nodes && Object.keys(latestState.nodes).length > 0
},
testId,
{ timeout: 10000 }
)
// Get the captured states
const eventState = await executionHelper.getEventState()
const result = eventState.progressStates
// Should have captured real events
expect(result.length).toBeGreaterThan(0)
const firstState = result[0]
expect(firstState).toBeDefined()
expect(firstState.prompt_id).toBeDefined()
expect(firstState.nodes).toBeDefined()
// Check that we got real node progress
const nodeIds = Object.keys(firstState.nodes)
expect(nodeIds.length).toBeGreaterThan(0)
// Verify node structure
for (const nodeId of nodeIds) {
const node = firstState.nodes[nodeId]
expect(node.state).toBeDefined()
expect(node.node_id).toBeDefined()
expect(node.display_node_id).toBeDefined()
expect(node.prompt_id).toBeDefined()
}
})
test('Canvas updates when nodes have progress', async ({ comfyPage, ws }) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Set up progress tracking
await executionHelper.setupEventTracking()
// Start real execution
await comfyPage.queueButton.click()
// Wait for nodes to have progress from real execution
const testId2 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
// Check if any nodes are running with progress
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running' && node.value > 0
)
return runningNodes.length > 0
},
testId2,
{ timeout: 10000 }
)
// Wait for progress to be applied to graph nodes
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
// Check that nodes have progress set from real execution
const node2Progress = await executionHelper.getGraphNodeProgress(2)
const node3Progress = await executionHelper.getGraphNodeProgress(3)
const node4Progress = await executionHelper.getGraphNodeProgress(4)
// At least one node should have progress
const hasProgress =
(node2Progress !== undefined && node2Progress > 0) ||
(node3Progress !== undefined && node3Progress > 0) ||
(node4Progress !== undefined && node4Progress > 0)
expect(hasProgress).toBe(true)
})
test('Preview events include metadata', async ({ comfyPage, ws }) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Track preview events
await previewHelper.setupPreviewTracking()
await executionHelper.setupEventTracking()
// Start real execution using command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// For this test, we'll check the event structure by simulating one
// since real preview events depend on the workflow actually generating images
await comfyPage.page.evaluate(() => {
const api = window['app'].api
// Simulate a preview event that would come from backend
api.dispatchCustomEvent('b_preview_with_metadata', {
blob: new Blob(['test'], { type: 'image/png' }),
nodeId: '10:5:3',
displayNodeId: '10',
parentNodeId: '10:5',
realNodeId: '3',
promptId: 'test-prompt-id'
})
})
await comfyPage.nextFrame()
// Check captured events
const captured = await previewHelper.getPreviewEvents()
expect(captured).toHaveLength(1)
expect(captured[0]).toEqual({
nodeId: '10:5:3',
displayNodeId: '10',
parentNodeId: '10:5',
realNodeId: '3',
promptId: 'test-prompt-id'
})
})
})

View File

@@ -27,21 +27,6 @@ test.describe('Node search box', () => {
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test('New user (1.24.1+) gets search box by default on link release', async ({
comfyPage
}) => {
// Start fresh to test new user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate new user with 1.24.1+ installed version
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.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add node', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
@@ -187,10 +172,10 @@ test.describe('Node search box', () => {
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
// Verify the filter selection panel is hidden
await expect(panel.header).not.toBeVisible()
expect(panel.header).not.toBeVisible()
// Verify the node search dialog is still visible
await expect(comfyPage.searchBox.input).toBeVisible()
expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add multiple filters', async ({ comfyPage }) => {
@@ -279,38 +264,4 @@ test.describe('Release context menu', () => {
'link-context-menu-search.png'
)
})
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
comfyPage
}) => {
// Start fresh to test existing user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate existing user with pre-1.24.1 version
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.disconnectEdge()
// Context menu should appear, search box should not
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
test('Explicit setting overrides versioned defaults', async ({
comfyPage
}) => {
// Start fresh and simulate new user who should get search box by default
await comfyPage.setup({ clearStorage: true })
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
// But explicitly set to context menu (overriding versioned default)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
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')
await expect(contextMenu).toBeVisible()
})
})

View File

@@ -0,0 +1,272 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
PreviewTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Preview with Metadata', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let previewHelper: PreviewTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
previewHelper = new PreviewTestHelper(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Handles b_preview_with_metadata event correctly', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Clear any existing previews
await comfyPage.page.evaluate(() => {
window['app'].nodePreviewImages = {}
})
// Set up handler to track preview events and execution
await executionHelper.setupEventTracking()
await comfyPage.page.evaluate(() => {
window['__previewHandled'] = false
const api = window['app'].api
// Add handler to track preview events
api.addEventListener('b_preview_with_metadata', (event) => {
const { displayNodeId, blob } = event.detail
// Create URL from the blob in the event
const url = URL.createObjectURL(blob)
window['app'].nodePreviewImages[displayNodeId] = [url]
window['__previewHandled'] = true
window['__lastPreviewUrl'] = url
})
})
// Start real execution to test event handling in context
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Trigger b_preview_with_metadata event (simulating what backend would send)
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const event = new CustomEvent('b_preview_with_metadata', {
detail: {
blob: new Blob(['test'], { type: 'image/png' }),
nodeId: '2',
displayNodeId: '2',
parentNodeId: '2',
realNodeId: '2',
promptId: 'test-prompt-id'
}
})
api.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Wait for preview to be handled
await comfyPage.page.waitForFunction(
() => window['__previewHandled'] === true,
{ timeout: 5000 }
)
// Check that preview was set for the correct node
const result = await comfyPage.page.evaluate(() => {
return {
previewImages: window['app'].nodePreviewImages,
lastUrl: window['__lastPreviewUrl']
}
})
expect(result.previewImages['2']).toBeDefined()
expect(result.previewImages['2']).toHaveLength(1)
expect(result.previewImages['2'][0]).toBe(result.lastUrl)
})
test('Clears old previews when new preview arrives', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Set up initial preview
const initialBlobUrl = await comfyPage.page.evaluate(() => {
const blob = new Blob(['initial image'], { type: 'image/png' })
const url = URL.createObjectURL(blob)
window['app'].nodePreviewImages['3'] = [url]
return url
})
// Create spy to track URL revocations
await previewHelper.setupPreviewTracking()
// Mock the handler to revoke old previews
await comfyPage.page.evaluate(() => {
const api = window['app'].api
api.addEventListener('b_preview_with_metadata', (event) => {
const { displayNodeId } = event.detail
window['app'].revokePreviews(displayNodeId)
const newBlob = new Blob(['new image'], { type: 'image/png' })
const newUrl = URL.createObjectURL(newBlob)
window['app'].nodePreviewImages[displayNodeId] = [newUrl]
})
})
// Trigger new preview for same node
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const event = new CustomEvent('b_preview_with_metadata', {
detail: {
blob: new Blob(['new image'], { type: 'image/png' }),
nodeId: '3',
displayNodeId: '3',
parentNodeId: '3',
realNodeId: '3',
promptId: 'test-prompt-id'
}
})
api.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Check that old URL was revoked
const finalRevokedUrls = await previewHelper.getRevokedUrls()
expect(finalRevokedUrls).toContain(initialBlobUrl)
// Check that new preview replaced old one
const newPreviewImages = await previewHelper.getNodePreviews('3')
expect(newPreviewImages).toHaveLength(1)
expect(newPreviewImages[0]).not.toBe(initialBlobUrl)
})
test('Associates preview with correct display node in subgraph', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Mock handler that stores metadata
await previewHelper.setupPreviewTracking()
await comfyPage.page.evaluate(() => {
window['__previewMetadata'] = {}
const api = window['app'].api
api.addEventListener('b_preview_with_metadata', (event) => {
const { displayNodeId, nodeId, parentNodeId, realNodeId, promptId } =
event.detail
window['__previewMetadata'][displayNodeId] = {
nodeId,
displayNodeId,
parentNodeId,
realNodeId,
promptId
}
// Still create the preview
const url = URL.createObjectURL(event.detail.blob)
window['app'].nodePreviewImages[displayNodeId] = [url]
})
})
// Simulate preview from a subgraph node
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const event = new CustomEvent('b_preview_with_metadata', {
detail: {
blob: new Blob(['subgraph preview'], { type: 'image/png' }),
nodeId: '10:5:3', // Nested execution ID
displayNodeId: '10', // Top-level display node
parentNodeId: '10:5', // Parent subgraph
realNodeId: '3', // Actual node ID within subgraph
promptId: 'test-prompt-id'
}
})
api.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Check that preview is associated with display node
const metadata = await comfyPage.page.evaluate(
() => window['__previewMetadata']
)
expect(metadata['10']).toBeDefined()
expect(metadata['10'].nodeId).toBe('10:5:3')
expect(metadata['10'].displayNodeId).toBe('10')
expect(metadata['10'].parentNodeId).toBe('10:5')
expect(metadata['10'].realNodeId).toBe('3')
// Check that preview exists for display node
const previews = await comfyPage.page.evaluate(
() => window['app'].nodePreviewImages
)
expect(previews['10']).toBeDefined()
expect(previews['10']).toHaveLength(1)
})
test('Maintains backward compatibility with b_preview event', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Track both events
const eventsFired = await comfyPage.page.evaluate(() => {
const events: string[] = []
const api = window['app'].api
api.addEventListener('b_preview', () => {
events.push('b_preview')
})
api.addEventListener('b_preview_with_metadata', () => {
events.push('b_preview_with_metadata')
})
window['__eventsFired'] = events
return events
})
// Trigger b_preview_with_metadata
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const blob = new Blob(['test image'], { type: 'image/png' })
// Simulate the API behavior
api.dispatchCustomEvent('b_preview_with_metadata', {
blob,
nodeId: '2',
displayNodeId: '2',
parentNodeId: '2',
realNodeId: '2',
promptId: 'test-prompt-id'
})
// Also dispatch legacy event as the API would
api.dispatchCustomEvent('b_preview', blob)
})
await comfyPage.nextFrame()
// Check that both events were fired
const finalEvents = await comfyPage.page.evaluate(
() => window['__eventsFired']
)
expect(finalEvents).toContain('b_preview_with_metadata')
expect(finalEvents).toContain('b_preview')
})
})

View File

@@ -1,469 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
promptDialog: '.graphdialog input',
nodeSearchContainer: '.node-search-container',
domWidget: '.comfy-multiline-input'
} as const
test.describe('Subgraph Operations', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// Helper to get subgraph slot count
async function getSubgraphSlotCount(
comfyPage: typeof test.prototype.comfyPage,
type: 'inputs' | 'outputs'
): Promise<number> {
return await comfyPage.page.evaluate((slotType) => {
return window['app'].canvas.graph[slotType]?.length || 0
}, type)
}
// Helper to get current graph node count
async function getGraphNodeCount(
comfyPage: typeof test.prototype.comfyPage
): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes?.length || 0
})
}
// Helper to verify we're in a subgraph
async function isInSubgraph(
comfyPage: typeof test.prototype.comfyPage
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
}
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphInputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphOutputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can rename I/O slots', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
const initialNodeCount = await getGraphNodeCount(comfyPage)
await comfyPage.ctrlA()
await comfyPage.nextFrame()
const node = await comfyPage.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await getGraphNodeCount(comfyPage)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
})
test.describe('Operations Inside Subgraphs', () => {
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await getGraphNodeCount(comfyPage)
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window['app'].canvas.graph.nodes
return nodes?.[0]?.id || null
})
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await getGraphNodeCount(comfyPage)
// Undo
await comfyPage.ctrlZ()
await comfyPage.nextFrame()
const afterUndoCount = await getGraphNodeCount(comfyPage)
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.ctrlY()
await comfyPage.nextFrame()
const afterRedoCount = await getGraphNodeCount(comfyPage)
expect(afterRedoCount).toBe(initialCount)
})
})
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10
},
delay: 5
})
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
await comfyPage.nextFrame()
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const workflowName = 'subgraph-with-promoted-text-widget'
await comfyPage.loadWorkflow(workflowName)
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.rightClickSubgraphInputSlot('text')
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
await comfyPage.page.waitForTimeout(200)
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.getByRole('link', {
// In the subgraph navigation breadcrumbs, the home/top level
// breadcrumb is just the workflow name
name: workflowName
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(300)
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-multiple-promoted-widgets')
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
})
})

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph Breadcrumb Title Sync', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
// Load a workflow with subgraphs
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
// Get the subgraph node by ID (node 10 is the subgraph)
const subgraphNode = await comfyPage.getNodeRefById('10')
// Get node position and double-click on it to enter the subgraph
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2 + 10
}
})
await comfyPage.nextFrame()
// Wait for breadcrumb to appear
await comfyPage.page.waitForSelector('.subgraph-breadcrumb', {
state: 'visible',
timeout: 20000
})
// Get initial breadcrumb text
const breadcrumb = comfyPage.page.locator('.subgraph-breadcrumb')
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back to main graph
await comfyPage.page.keyboard.press('Escape')
// Double-click on the title area of the subgraph node to edit
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10 // Title area is above the node body
},
delay: 5
})
// Wait for title editor to appear
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
// Clear existing text and type new title
await comfyPage.page.keyboard.press('Control+a')
const newTitle = 'Updated Subgraph Title'
await comfyPage.page.keyboard.type(newTitle)
await comfyPage.page.keyboard.press('Enter')
// Wait a frame for the update to complete
await comfyPage.nextFrame()
// Enter the subgraph again
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2
},
delay: 5
})
// Wait for breadcrumb
await comfyPage.page.waitForSelector('.subgraph-breadcrumb')
// Check that breadcrumb now shows the new title
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(newTitle)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})

View File

@@ -0,0 +1,237 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
SubgraphTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Subgraph Execution Progress', () => {
test.describe.configure({ mode: 'serial' })
test.setTimeout(30000) // Increase timeout for subgraph tests
let executionHelper: ExecutionTestHelper
let subgraphHelper: SubgraphTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
subgraphHelper = new SubgraphTestHelper(comfyPage.page)
// Share the same test ID to access the same window properties
subgraphHelper.setTestId(executionHelper.getTestId())
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Shows progress for nodes inside subgraphs', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
// Get reference to the subgraph node
const subgraphNode = await comfyPage.getNodeRefById(10)
expect(subgraphNode).toBeDefined()
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start real execution using command to avoid click issues
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real progress events from subgraph execution
await subgraphHelper.waitForNestedNodeProgress(1)
// Wait for progress to be applied to the subgraph node
await executionHelper.waitForGraphNodeProgress([10])
// Check that the subgraph node shows aggregated progress
const subgraphProgress = await subgraphNode.getProperty('progress')
// The progress should be aggregated from child nodes
expect(subgraphProgress).toBeDefined()
expect(subgraphProgress).toBeGreaterThan(0)
expect(subgraphProgress).toBeLessThanOrEqual(1)
// Wait for stroke style to be applied
await comfyPage.page.waitForFunction(
(nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node?.strokeStyles?.['running']) return false
const style = node.strokeStyles['running'].call(node)
return style?.color === '#0f0'
},
10,
{ timeout: 5000 }
)
// Check stroke style
const strokeStyle = await comfyPage.page.evaluate((nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node || !node.strokeStyles || !node.strokeStyles['running']) {
return null
}
return node.strokeStyles['running'].call(node)
}, 10)
expect(strokeStyle).toEqual({ color: '#0f0' })
})
test('Handles deeply nested subgraph execution', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start real execution using command to avoid click issues
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real progress events from deeply nested subgraph execution
await subgraphHelper.waitForNestedNodeProgress(2)
// Wait for progress to be applied to the top-level subgraph node
await executionHelper.waitForGraphNodeProgress([10])
// Check that top-level subgraph shows progress
const subgraphNode = await comfyPage.getNodeRefById(10)
const progress = await subgraphNode.getProperty('progress')
expect(progress).toBeDefined()
expect(progress).toBeGreaterThan(0)
expect(progress).toBeLessThanOrEqual(1)
})
test('Shows running state for parent nodes when child executes', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
// Track which nodes have running stroke style
const getRunningNodes = async () => {
return await comfyPage.page.evaluate(() => {
const runningNodes: number[] = []
const nodes = window['app'].graph.nodes
for (const node of nodes) {
if (
node.strokeStyles?.['running'] &&
node.strokeStyles['running'].call(node)?.color === '#0f0'
) {
runningNodes.push(node.id)
}
}
return runningNodes
})
}
// Wait for any existing execution to complete
await comfyPage.page
.waitForFunction(
() => {
const nodes = window['app'].graph.nodes
for (const node of nodes) {
if (
node.strokeStyles?.['running'] &&
node.strokeStyles['running'].call(node)?.color === '#0f0'
) {
return false
}
}
return true
},
{ timeout: 10000 }
)
.catch(() => {
// If timeout, continue anyway
})
// Initially no nodes should be running
let runningNodes = await getRunningNodes()
expect(runningNodes).toHaveLength(0)
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start real execution using command to avoid click issues
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real nested node execution progress
await subgraphHelper.waitForNestedNodeProgress(1)
// Wait for parent subgraph to show as running
await comfyPage.page.waitForFunction(
(nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node?.strokeStyles?.['running']) return false
const style = node.strokeStyles['running'].call(node)
return style?.color === '#0f0'
},
10,
{ timeout: 5000 }
)
// Parent subgraph should show as running
runningNodes = await getRunningNodes()
expect(runningNodes).toContain(10)
// Wait for the execution to complete naturally
const testId = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
// Check if execution is finished (no more running nodes)
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running'
).length
return runningNodes === 0
},
testId,
{ timeout: 10000 }
)
// Add a small delay to ensure UI updates
await comfyPage.page.waitForTimeout(500)
// Wait for parent subgraph to no longer be running
await comfyPage.page.waitForFunction(
(nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node?.strokeStyles?.['running']) return true
const style = node.strokeStyles['running'].call(node)
return style?.color !== '#0f0'
},
10,
{ timeout: 5000 }
)
// Parent should no longer be running
runningNodes = await getRunningNodes()
expect(runningNodes).not.toContain(10)
})
})

View File

@@ -48,7 +48,7 @@ test.describe('Combo text widget', () => {
await comfyPage.page.keyboard.press('r')
// Wait for nodes' widgets to be updated
await comfyPage.page.waitForTimeout(500)
await comfyPage.nextFrame()
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)

72
dangerfile.ts Normal file
View File

@@ -0,0 +1,72 @@
import { danger, fail } from 'danger'
// Check if we should run the checks
const shouldRunChecks = async () => {
const allChangedFiles = [
...danger.git.modified_files,
...danger.git.created_files
]
const srcChanges = allChangedFiles.filter((file) => file.startsWith('src/'))
if (srcChanges.length === 0) {
return false
}
// Check total lines changed in src files
let totalLinesChanged = 0
for (const file of srcChanges) {
const diff = await danger.git.diffForFile(file)
if (diff) {
// Count only lines with actual content (non-empty after trimming whitespace)
// This excludes empty lines and lines containing only spaces/tabs
const additions =
diff.added?.split('\n').filter((line) => line.trim()).length || 0
const deletions =
diff.removed?.split('\n').filter((line) => line.trim()).length || 0
totalLinesChanged += additions + deletions
}
}
return totalLinesChanged > 3
}
// Check if browser tests were updated
const checkBrowserTestCoverage = () => {
const allChangedFiles = [
...danger.git.modified_files,
...danger.git.created_files
]
const hasBrowserTestChanges = allChangedFiles.some(
(file) => file.startsWith('browser_tests/') && file.endsWith('.ts')
)
if (!hasBrowserTestChanges) {
fail(`🧪 **E2E Test Coverage Missing**
All changes should be covered under E2E testing. Please add or update browser tests.`)
}
}
// Check for screen recording in PR description
const checkScreenRecording = () => {
const description = danger.github.pr.body || ''
const hasRecording =
/github\.com\/user-attachments\/assets\/[a-f0-9-]+/i.test(description) ||
/youtube\.com\/watch|youtu\.be\//i.test(description)
if (!hasRecording) {
fail(`📹 **Visual Documentation Missing**
Please add a screen recording or screenshot:
- GitHub: Drag & drop media to PR description
- YouTube: Add YouTube link`)
}
}
// Run the checks only if conditions are met
shouldRunChecks().then((shouldRun) => {
if (shouldRun) {
checkBrowserTestCoverage()
checkScreenRecording()
}
})

1029
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.1",
"version": "1.24.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -45,6 +45,7 @@
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19",
"chalk": "^5.3.0",
"danger": "^13.0.4",
"eslint": "^9.12.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
@@ -77,7 +78,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.18",
"@comfyorg/litegraph": "^0.16.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -96,8 +97,6 @@
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",

View File

@@ -30,11 +30,10 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
const settingsStore = useSettingStore()
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
@@ -50,16 +49,7 @@ const {
} = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
if (position.value === 'Top') {
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
if (event.y < minY) {
event.y = minY
}
}
}
containerElement: document.body
})
// Update storedPosition when x or y changes
@@ -192,6 +182,7 @@ const adjustMenuPosition = () => {
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {

View File

@@ -1,70 +1,34 @@
<template>
<div
class="subgraph-breadcrumb w-auto"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
}"
:style="{
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<Breadcrumb
ref="breadcrumbRef"
class="bg-transparent p-0"
class="bg-transparent"
:home="home"
:model="items"
aria-label="Graph navigation"
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:item="item"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
><span style="transform: scale(1.5)"> / </span></template
>
</Breadcrumb>
@item-click="handleItemClick"
/>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const MIN_WIDTH = 28
const ITEM_GAP = 8
const ITEM_PADDING = 8
const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
if (!navigationStore.navigationStack.length) return []
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
@@ -73,14 +37,11 @@ const items = computed(() => {
canvas.setGraph(subgraph)
}
}))
return [home.value, ...items]
})
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -89,6 +50,10 @@ const home = computed(() => ({
}
}))
const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
@@ -100,116 +65,21 @@ useEventListener(document, 'keydown', (event) => {
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {
overflowObserver?.dispose()
overflowObserver = undefined
if (!el) return
overflowObserver = useOverflowObserver(el, {
onCheck: (isOverflowing) => {
overflowingTabs.value = isOverflowing
if (collapseTabs.value) {
// Items are currently hidden, check if we can show them
if (!isOverflowing) {
const items = [
...el.querySelectorAll('.p-breadcrumb-item')
] as HTMLElement[]
if (items.length < 3) return
const itemsWithIcon = items.filter((item) =>
item.querySelector('.p-breadcrumb-item-link-icon-visible')
).length
const separators = el.querySelectorAll(
'.p-breadcrumb-separator'
) as NodeListOf<HTMLElement>
const separator = separators[separators.length - 1] as HTMLElement
const separatorWidth = separator.offsetWidth
// items + separators + gaps + icons
const itemsWidth =
(MIN_WIDTH + ITEM_PADDING + ITEM_PADDING) * items.length +
itemsWithIcon * ICON_WIDTH
const separatorsWidth = (items.length - 1) * separatorWidth
const gapsWidth = (items.length - 1) * (ITEM_GAP * 2)
const totalWidth = itemsWidth + separatorsWidth + gapsWidth
const containerWidth = el.clientWidth
if (totalWidth <= containerWidth) {
collapseTabs.value = false
}
}
} else if (isOverflowing) {
collapseTabs.value = true
}
}
})
})
// If e.g. the workflow name changes, we need to check the overflow again
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style scoped>
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
min-width: 120px;
}
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
@apply overflow-hidden;
}
:deep(.p-breadcrumb-item) {
@apply flex items-center rounded-lg overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
}
:deep(.p-breadcrumb-item:first-child) {
/* Then collapse the root workflow */
flex-shrink: 5000;
}
:deep(.p-breadcrumb-item:last-child) {
/* Then collapse the active item */
flex-shrink: 1;
}
:deep(.p-breadcrumb-item:hover),
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
color: var(--fg-color);
}
</style>
<style>
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
@apply hidden;
}
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply block;
color: #d26565;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
}
}
</style>

View File

@@ -1,209 +0,0 @@
<template>
<a
ref="wrapperRef"
v-tooltip.bottom="item.label"
href="#"
class="cursor-pointer p-breadcrumb-item-link"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
}"
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-secondary-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
<InputText
v-if="isEditing"
ref="itemInputRef"
v-model="itemLabel"
class="fixed z-[10000] text-[.8rem] px-2 py-2"
@blur="inputBlur(true)"
@click.stop
@keydown.enter="inputBlur(true)"
@keydown.esc="inputBlur(false)"
/>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { appendJsonExt } from '@/utils/formatUtil'
interface Props {
item: MenuItem
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const dialogService = useDialogService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const isEditing = ref(false)
const itemLabel = ref<string>()
const itemInputRef = ref<{ $el?: HTMLInputElement }>()
const wrapperRef = ref<HTMLAnchorElement>()
const rename = async (
newName: string | null | undefined,
initialName: string
) => {
if (newName && newName !== initialName) {
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + appendJsonExt(newName)
)
} catch (error) {
console.error(error)
dialogService.showErrorDialog(error)
return
}
}
// Force the navigation stack to recompute the labels
// TODO: investigate if there is a better way to do this
const navigationStore = useSubgraphNavigationStore()
navigationStore.restoreState(navigationStore.exportState())
}
}
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root'
},
{
label: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
}
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
return
}
if (event.detail === 1) {
if (props.isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
}
isEditing.value = false
}
</script>
<style scoped>
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
}
.p-breadcrumb-item-link {
@apply overflow-hidden;
padding: var(--p-breadcrumb-item-padding);
}
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
</style>

View File

@@ -11,6 +11,7 @@
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
@@ -20,35 +21,24 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
const node = widget.node as LGraphNode
// Early exit for non-visible widgets
if (!widget.isVisible()) {
widgetState.visible = false
continue
}
// Check if the widget's node is in the current graph
const node = widget.node
const isInCorrectGraph = currentGraph?.nodes.includes(node)
widgetState.visible =
!!isInCorrectGraph &&
const visible =
lgCanvas.isNodeVisible(node) &&
!(widget.options.hideOnZoom && lowQuality)
!(widget.options.hideOnZoom && lowQuality) &&
widget.isVisible()
if (widgetState.visible && node) {
widgetState.visible = visible
if (visible) {
const margin = widget.margin
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [

View File

@@ -16,14 +16,9 @@
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
<MiniMap
v-if="comfyAppReady && minimapEnabled"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
</LiteGraphCanvasSplitterOverlay>
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
@@ -55,9 +50,9 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import MiniMap from '@/components/graph/MiniMap.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
@@ -72,7 +67,6 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
@@ -119,10 +113,6 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -358,13 +348,6 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -56,15 +56,6 @@
data-testid="toggle-link-visibility-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
/>
<Button
v-tooltip.left="t('graphCanvasMenu.toggleMinimap') + ' (Alt + m)'"
severity="secondary"
:icon="'pi pi-map'"
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
:class="{ 'minimap-active': minimapVisible }"
data-testid="toggle-minimap-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
/>
</ButtonGroup>
</template>
@@ -84,7 +75,6 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)
@@ -117,15 +107,4 @@ const stopRepeat = () => {
margin: 0;
border-radius: 0;
}
.p-button.minimap-active {
background-color: var(--p-button-primary-background);
border-color: var(--p-button-primary-border-color);
color: var(--p-button-primary-color);
}
.p-button.minimap-active:hover {
background-color: var(--p-button-primary-hover-background);
border-color: var(--p-button-primary-hover-border-color);
}
</style>

View File

@@ -1,88 +0,0 @@
<template>
<div
v-if="visible && initialized"
ref="containerRef"
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
:style="containerStyles"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const {
initialized,
visible,
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,
height,
init,
destroy,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel
} = minimap
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
})
onUnmounted(() => {
destroy()
})
</script>
<style scoped>
.litegraph-minimap {
overflow: hidden;
}
.minimap-canvas {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.minimap-viewport {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
</style>

View File

@@ -19,7 +19,7 @@
<script setup lang="ts">
import { useElementBounding, useEventListener } from '@vueuse/core'
import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
@@ -61,13 +61,10 @@ const updateDomClipping = () => {
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
if (!selectedNode) {
// Clear clipping when no node is selected
updateClipPath(widgetElement.value, lgCanvas.canvas, false, undefined)
return
}
if (!selectedNode) return
const isSelected = selectedNode === widget.node
const node = widget.node
const isSelected = selectedNode === node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -125,10 +122,7 @@ watch(
}
)
// Set up event listeners only after the widget is mounted and visible
const setupDOMEventListeners = () => {
if (!isDOMWidget(widget) || !widgetState.visible) return
if (isDOMWidget(widget)) {
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
@@ -146,46 +140,14 @@ const setupDOMEventListeners = () => {
}
}
// Set up event listeners when widget becomes visible
watch(
() => widgetState.visible,
(visible) => {
if (visible) {
setupDOMEventListeners()
}
},
{ immediate: true }
)
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
// Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => {
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
// Only append if not already a child
if (!widgetElement.value.contains(widget.element)) {
widgetElement.value.appendChild(widget.element)
}
}
}
// Check on mount - but only after next tick to ensure visibility is calculated
onMounted(() => {
nextTick(() => {
mountElementIfVisible()
}).catch((error) => {
console.error('Error mounting DOM widget element:', error)
})
})
// And watch for visibility changes
watch(
() => widgetState.visible,
() => {
mountElementIfVisible()
if (isDOMWidget(widget) && widgetElement.value) {
widgetElement.value.appendChild(widget.element)
}
)
})
</script>
<style scoped>

View File

@@ -217,8 +217,7 @@ defineExpose({
display: flex;
flex-direction: column;
gap: 24px;
max-height: 80vh;
overflow-y: auto;
overflow: hidden;
padding: 32px 32px 24px;
border-radius: 12px;
}

View File

@@ -55,6 +55,7 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
@export-model="handleExportModel"
/>
<div
@@ -214,6 +215,30 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
node.properties['Background Image'] = backgroundImage.value
}
const handleUploadTexture = async (file: File) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const texturePath = await Load3dUtils.uploadFile(file, subfolder)
await load3DSceneRef.value.load3d.applyTexture(texturePath)
node.properties['Texture'] = texturePath
} catch (error) {
console.error('Error applying texture:', error)
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
}
}
const handleUpdateFOV = (value: number) => {
fov.value = value

View File

@@ -51,6 +51,7 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
/>
<CameraControls
@@ -182,6 +183,7 @@ const emit = defineEmits<{
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
(e: 'uploadTexture', file: File): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -230,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUploadTexture = (file: File) => {
emit('uploadTexture', file)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}

View File

@@ -69,6 +69,9 @@ const eventConfig = {
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
textureLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
recordingStatusChange: (value: boolean) =>
emit('recordingStatusChange', value)
} as const

View File

@@ -59,6 +59,32 @@
</div>
</div>
<div
v-if="
materialMode === 'original' &&
!props.inputSpec.isAnimation &&
!props.inputSpec.isPreview
"
class="relative show-texture-upload"
>
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
<i
v-tooltip.right="{
value: t('load3d.uploadTexture'),
showDelay: 300
}"
class="pi pi-image text-white text-lg"
/>
<input
ref="texturePickerRef"
type="file"
accept="image/*"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
@change="uploadTexture"
/>
</Button>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@@ -116,6 +142,7 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'uploadTexture', file: File): void
}>()
const upDirection = ref(props.upDirection || 'original')
@@ -124,6 +151,7 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const texturePickerRef = ref<HTMLInputElement | null>(null)
const upDirections: UpDirection[] = [
'original',
@@ -219,6 +247,18 @@ const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const openTextureUpload = () => {
texturePickerRef.value?.click()
}
const uploadTexture = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('uploadTexture', input.files[0])
}
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -14,8 +14,9 @@
/>
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarThemeToggleIcon />
<SidebarHelpCenterIcon />
<SidebarBottomPanelToggleButton />
<SidebarSettingsToggleIcon />
</div>
</nav>
</teleport>
@@ -31,7 +32,6 @@
import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useUserStore } from '@/stores/userStore'
@@ -41,6 +41,8 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()

View File

@@ -1,19 +0,0 @@
<template>
<SidebarIcon
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.bottomPanelVisible"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-ph:terminal-bold />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -19,12 +19,10 @@
@click="emit('click', $event)"
>
<template #icon>
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</slot>
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</template>
</Button>
</template>

View File

@@ -0,0 +1,25 @@
<template>
<SidebarIcon
icon="pi pi-cog"
class="comfy-settings-btn"
:tooltip="$t('g.settings')"
@click="showSetting"
/>
</template>
<script setup lang="ts">
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import SidebarIcon from './SidebarIcon.vue'
const dialogStore = useDialogStore()
const showSetting = () => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent
})
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<SidebarIcon
:icon="icon"
:tooltip="$t('sideToolbar.themeToggle')"
class="comfy-vue-theme-toggle"
@click="toggleTheme"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import SidebarIcon from './SidebarIcon.vue'
const colorPaletteStore = useColorPaletteStore()
const icon = computed(() =>
colorPaletteStore.completedActivePalette.light_theme
? 'pi pi-sun'
: 'pi pi-moon'
)
const commandStore = useCommandStore()
const toggleTheme = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<Button
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
severity="secondary"
text
:aria-label="$t('menu.toggleBottomPanel')"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-material-symbols:dock-to-bottom
v-if="bottomPanelStore.bottomPanelVisible"
/>
<i-material-symbols:dock-to-bottom-outline v-else />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -1,116 +1,51 @@
<template>
<div
class="comfyui-logo-wrapper p-1 flex justify-center items-center cursor-pointer rounded-md mr-2"
:class="{
'comfyui-logo-menu-visible': menuRef?.visible
}"
:style="{
minWidth: isLargeSidebar ? '4rem' : 'auto'
}"
@click="menuRef?.toggle($event)"
>
<img
src="/assets/images/comfy-logo-mono.svg"
alt="ComfyUI Logo"
class="comfyui-logo h-7"
@contextmenu="showNativeSystemMenu"
/>
<i class="pi pi-angle-down ml-1 text-[10px]" />
</div>
<TieredMenu
ref="menuRef"
<Menubar
:model="translatedItems"
:popup="true"
class="comfy-command-menu"
:class="{
'comfy-command-menu-top': isTopMenu
class="top-menubar border-none p-0 bg-transparent"
:pt="{
rootList: 'gap-0 flex-nowrap w-auto',
submenu: `dropdown-direction-${dropdownDirection}`,
item: 'relative'
}"
@show="onMenuShow"
>
<template #item="{ item, props }">
<div
v-if="item.key === 'theme'"
class="flex items-center gap-4 px-4 py-5"
@click.stop.prevent
>
{{ item.label }}
<SelectButton
:options="[darkLabel, lightLabel]"
:model-value="activeTheme"
@click.stop.prevent
@update:model-value="onThemeChange"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<i v-if="option === lightLabel" class="pi pi-sun" />
<i v-if="option === darkLabel" class="pi pi-moon" />
<span>{{ option }}</span>
</div>
</template>
</SelectButton>
</div>
<template #item="{ item, props, root }">
<a
v-else
class="p-menubar-item-link px-4 py-2"
class="p-menubar-item-link"
v-bind="props.action"
:href="item.url"
target="_blank"
>
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<span class="p-menubar-item-label">{{ item.label }}</span>
<span
v-if="item?.comfyCommand?.keybinding"
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
>
{{ item.comfyCommand.keybinding.combo.toString() }}
</span>
<i v-if="item.items" class="ml-auto pi pi-angle-right" />
<i v-if="!root && item.items" class="ml-auto pi pi-angle-right" />
</a>
</template>
</TieredMenu>
<SubgraphBreadcrumb />
</Menubar>
</template>
<script setup lang="ts">
import Menubar from 'primevue/menubar'
import type { MenuItem } from 'primevue/menuitem'
import SelectButton from 'primevue/selectbutton'
import TieredMenu, {
type TieredMenuMethods,
type TieredMenuState
} from 'primevue/tieredmenu'
import { computed, nextTick, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const aboutPanelStore = useAboutPanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const isLargeSidebar = computed(
() => settingStore.get('Comfy.Sidebar.Size') !== 'small'
const dropdownDirection = computed(() =>
settingStore.get('Comfy.UseNewMenu') === 'Top' ? 'down' : 'up'
)
const isTopMenu = computed(() => settingStore.get('Comfy.UseNewMenu') === 'Top')
const menuItemsStore = useMenuItemStore()
const { t } = useI18n()
const translateMenuItem = (item: MenuItem): MenuItem => {
const label = typeof item.label === 'function' ? item.label() : item.label
const translatedLabel = label
@@ -124,119 +59,9 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
}
}
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
}
// Temporary duplicated from LoadWorkflowWarning.vue
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const showManageExtensions = () => {
if (isManagerInstalled.value) {
useDialogService().showManagerDialog()
} else {
showSettings('extension')
}
}
const extraMenuItems: MenuItem[] = [
{ separator: true },
{
key: 'theme',
label: t('menu.theme')
},
{ separator: true },
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
}
]
const lightLabel = t('menu.light')
const darkLabel = t('menu.dark')
const activeTheme = computed(() => {
return colorPaletteStore.completedActivePalette.light_theme
? lightLabel
: darkLabel
})
const onThemeChange = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
const translatedItems = computed(() => {
const items = menuItemsStore.menuItems.map(translateMenuItem)
let helpIndex = items.findIndex((item) => item.key === 'Help')
let helpItem: MenuItem | undefined
if (helpIndex !== -1) {
items[helpIndex].icon = 'mdi mdi-help-circle-outline'
// If help is not the last item (i.e. we have extension commands), separate them
const isLastItem = helpIndex !== items.length - 1
helpItem = items.splice(
helpIndex,
1,
...(isLastItem
? [
{
separator: true
}
]
: [])
)[0]
}
helpIndex = items.length
items.splice(
helpIndex,
0,
...extraMenuItems,
...(helpItem
? [
{
separator: true
},
helpItem
]
: [])
)
return items
})
const onMenuShow = () => {
void nextTick(() => {
// Force the menu to show submenus on hover
if (menuRef.value) {
menuRef.value.dirty = true
}
})
}
const translatedItems = computed(() =>
menuItemsStore.menuItems.map(translateMenuItem)
)
</script>
<style scoped>
@@ -249,20 +74,4 @@ const onMenuShow = () => {
border-color: var(--p-content-border-color);
border-style: solid;
}
.comfyui-logo-menu-visible,
.comfyui-logo-wrapper:hover {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
}
</style>
<style>
.comfy-command-menu ul {
background-color: var(--comfy-menu-secondary-bg) !important;
}
.comfy-command-menu-top .p-tieredmenu-submenu {
left: calc(100% + 15px) !important;
top: -4px !important;
}
</style>

View File

@@ -1,66 +1,82 @@
<template>
<div>
<div
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
class="w-full flex content-end z-[1001] h-[38px]"
style="background: var(--border-color)"
>
<WorkflowTabs />
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<img
src="/assets/images/comfy-logo-mono.svg"
alt="ComfyUI Logo"
class="comfyui-logo ml-2 app-drag h-6"
/>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<div ref="menuRight" class="comfyui-menu-right flex-shrink-0" />
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeSystemMenu"
/>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full"></div>
<div
ref="menuRight"
class="comfyui-menu-right flex-shrink-1 overflow-auto"
/>
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
/>
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</template>
<script setup lang="ts">
import { useEventBus } from '@vueuse/core'
import Button from 'primevue/button'
import { computed, onMounted, provide, ref } from 'vue'
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
import {
electronAPI,
isElectron,
isNativeWindow,
showNativeSystemMenu
} from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const menuRight = ref<HTMLDivElement | null>(null)
// Menu-right holds legacy topbar elements attached by custom scripts
onMounted(() => {
if (menuRight.value) {
app.menu.element.style.width = 'fit-content'
menuRight.value.appendChild(app.menu.element)
}
})
@@ -122,35 +138,4 @@ onMounted(() => {
.dark-theme .comfyui-logo {
filter: invert(1);
}
.comfyui-menu-button-hide {
background-color: var(--comfy-menu-secondary-bg);
border-left: 1px solid var(--border-color);
}
</style>
<style>
.comfyui-menu-right::-webkit-scrollbar {
max-height: 5px;
}
.comfyui-menu-right:hover::-webkit-scrollbar {
cursor: grab;
}
.comfyui-menu-right::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--border-color) 60%, transparent);
}
.comfyui-menu-right:hover::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--border-color) 80%, transparent);
}
.comfyui-menu-right::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fg-color) 30%, transparent);
}
.comfyui-menu-right::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--fg-color) 80%, transparent);
}
</style>

View File

@@ -1,11 +1,8 @@
<template>
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
<span
v-tooltip.bottom="{
value: workflowOption.workflow.key,
class: 'workflow-tab-tooltip'
}"
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
v-tooltip.bottom="workflowOption.workflow.key"
class="workflow-label text-sm max-w-[150px] truncate inline-block"
>
{{ workflowOption.workflow.filename }}
</span>
@@ -145,9 +142,3 @@ usePragmaticDroppable(tabGetter, {
transform: translate(-50%, -50%);
}
</style>
<style>
.p-tooltip.workflow-tab-tooltip {
z-index: 1200 !important;
}
</style>

View File

@@ -1,17 +1,5 @@
<template>
<div
class="workflow-tabs-container flex flex-row max-w-full h-full flex-auto overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
<Button
v-if="showOverflowArrows"
icon="pi pi-chevron-left"
text
severity="secondary"
class="overflow-arrow overflow-arrow-left"
:disabled="!leftArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(-1))"
/>
<div class="workflow-tabs-container flex flex-row max-w-full h-full">
<ScrollPanel
ref="scrollPanelRef"
class="overflow-hidden no-drag"
@@ -39,18 +27,9 @@
</template>
</SelectButton>
</ScrollPanel>
<Button
v-if="showOverflowArrows"
icon="pi pi-chevron-right"
text
severity="secondary"
class="overflow-arrow overflow-arrow-right"
:disabled="!rightArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(1))"
/>
<Button
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
class="new-blank-workflow-button flex-shrink-0 no-drag rounded-none"
class="new-blank-workflow-button flex-shrink-0 no-drag"
icon="pi pi-plus"
text
severity="secondary"
@@ -58,32 +37,23 @@
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
/>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div
v-if="menuSetting !== 'Bottom' && isDesktop"
class="window-actions-spacer flex-shrink-0 app-drag"
/>
</div>
</template>
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import Button from 'primevue/button'
import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
interface WorkflowOption {
value: string
@@ -97,19 +67,11 @@ const props = defineProps<{
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const settingStore = useSettingStore()
const workflowService = useWorkflowService()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const scrollPanelRef = ref()
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
const isDesktop = isElectron()
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
value: workflow.path,
@@ -124,7 +86,6 @@ const selectedWorkflow = computed<WorkflowOption | null>(() =>
? workflowToOption(workflowStore.activeWorkflow as ComfyWorkflow)
: null
)
const onWorkflowChange = async (option: WorkflowOption) => {
// Prevent unselecting the current workflow
if (!option) {
@@ -217,13 +178,6 @@ const handleWheel = (event: WheelEvent) => {
})
}
const scroll = (direction: number) => {
const scrollElement = scrollPanelRef.value.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
scrollElement.scrollBy({ left: direction * 20 })
}
// Scroll to active offscreen tab when opened
watch(
() => workflowStore.activeWorkflow,
@@ -254,71 +208,12 @@ watch(
},
{ immediate: true }
)
const scrollContent = computed(
() =>
scrollPanelRef.value?.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let overflowWatch: ReturnType<typeof watch> | null = null
watch(scrollContent, (value) => {
const scrollState = useScroll(value)
watch(scrollState.arrivedState, () => {
leftArrowEnabled.value = !scrollState.arrivedState.left
rightArrowEnabled.value = !scrollState.arrivedState.right
})
overflowObserver?.dispose()
overflowWatch?.stop()
overflowObserver = useOverflowObserver(value)
overflowWatch = watch(
overflowObserver.isOverflowing,
(value) => {
showOverflowArrows.value = value
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
})
},
{ immediate: true }
)
})
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style scoped>
.workflow-tabs-container {
background-color: var(--comfy-menu-secondary-bg);
}
:deep(.p-togglebutton) {
@apply p-0 bg-transparent rounded-none flex-shrink relative border-0 border-r border-solid;
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative border-0 border-r border-solid;
border-right-color: var(--border-color);
min-width: 90px;
}
.overflow-arrow {
@apply px-2 rounded-none;
}
.overflow-arrow[disabled] {
@apply opacity-25;
}
:deep(.p-togglebutton > .p-togglebutton-content) {
@apply max-w-full;
}
:deep(.workflow-tab) {
@apply max-w-full;
}
:deep(.p-togglebutton::before) {
@@ -360,10 +255,6 @@ onUpdated(() => {
@apply h-full;
}
:deep(.workflow-tabs) {
display: flex;
}
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
@@ -373,15 +264,4 @@ onUpdated(() => {
:deep(.p-selectbutton) {
@apply rounded-none h-full;
}
.workflow-tabs-container-desktop {
max-width: env(titlebar-area-width, 100vw);
}
.window-actions-spacer {
@apply flex-auto;
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
min-width: var(--window-actions-spacer-width);
}
</style>

View File

@@ -1,136 +0,0 @@
import { LGraphCanvas } from '@comfyorg/litegraph'
import { onUnmounted, ref } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called when sync stops
*/
onStop?: () => void
}
interface CanvasTransform {
scale: number
offsetX: number
offsetY: number
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
*
* The sync function typically reads canvas.ds properties like offset and scale to keep
* Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const syncWithCanvas = (canvas: LGraphCanvas) => {
* canvas.ds.scale
* canvas.ds.offset
* }
*
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* syncWithCanvas,
* {
* autoStart: false,
* onStart: () => emit('rafStatusChange', true),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
syncFn: (canvas: LGraphCanvas) => void,
options: CanvasTransformSyncOptions = {}
) {
const { onStart, onStop, autoStart = true } = options
const { getCanvas } = useCanvasStore()
const isActive = ref(false)
let rafId: number | null = null
let lastTransform: CanvasTransform = {
scale: 0,
offsetX: 0,
offsetY: 0
}
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
const ds = canvas.ds
return (
ds.scale !== lastTransform.scale ||
ds.offset[0] !== lastTransform.offsetX ||
ds.offset[1] !== lastTransform.offsetY
)
}
const sync = () => {
if (!isActive.value) return
const canvas = getCanvas()
if (!canvas) return
try {
// Only run sync if transform actually changed
if (hasTransformChanged(canvas)) {
lastTransform = {
scale: canvas.ds.scale,
offsetX: canvas.ds.offset[0],
offsetY: canvas.ds.offset[1]
}
syncFn(canvas)
}
} catch (error) {
console.error('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
const startSync = () => {
if (isActive.value) return
isActive.value = true
onStart?.()
// Reset last transform to force initial sync
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
sync()
}
const stopSync = () => {
isActive.value = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
onStop?.()
}
onUnmounted(stopSync)
if (autoStart) {
startSync()
}
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -1,64 +0,0 @@
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { debounce } from 'lodash'
import { readonly, ref } from 'vue'
/**
* Observes an element for overflow changes and optionally debounces the check
* @param element - The element to observe
* @param options - The options for the observer
* @param options.debounceTime - The time to debounce the check in milliseconds
* @param options.useMutationObserver - Whether to use a mutation observer to check for overflow
* @param options.useResizeObserver - Whether to use a resize observer to check for overflow
* @returns An object containing the isOverflowing state and the checkOverflow function to manually trigger
*/
export const useOverflowObserver = (
element: HTMLElement,
options?: {
debounceTime?: number
useMutationObserver?: boolean
useResizeObserver?: boolean
onCheck?: (isOverflowing: boolean) => void
}
) => {
options = {
debounceTime: 25,
useMutationObserver: true,
useResizeObserver: true,
...options
}
const isOverflowing = ref(false)
const disposeFns: (() => void)[] = []
const disposed = ref(false)
const checkOverflowFn = () => {
isOverflowing.value = element.scrollWidth > element.clientWidth
options.onCheck?.(isOverflowing.value)
}
const checkOverflow = options.debounceTime
? debounce(checkOverflowFn, options.debounceTime)
: checkOverflowFn
if (options.useMutationObserver) {
disposeFns.push(
useMutationObserver(element, checkOverflow, {
subtree: true,
childList: true
}).stop
)
}
if (options.useResizeObserver) {
disposeFns.push(useResizeObserver(element, checkOverflow).stop)
}
return {
isOverflowing: readonly(isOverflowing),
disposed: readonly(disposed),
checkOverflow,
dispose: () => {
disposed.value = true
disposeFns.forEach((fn) => fn())
}
}
}

View File

@@ -1004,194 +1004,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$2.25/Run'
}
},
// Runway nodes - using actual node names from ComfyUI
RunwayTextToImageNode: {
displayPrice: '$0.08/Run'
},
RunwayImageToVideoNodeGen3a: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
RunwayImageToVideoNodeGen4: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
RunwayFirstLastFrameNode: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value) || 5
const cost = (0.05 * duration).toFixed(2)
return `$${cost}/Run`
}
},
// Rodin nodes - all have the same pricing structure
Rodin3D_Regular: {
displayPrice: '$0.4/Run'
},
Rodin3D_Detail: {
displayPrice: '$1.2/Run'
},
Rodin3D_Smooth: {
displayPrice: '$1.2/Run'
},
Rodin3D_Sketch: {
displayPrice: '$0.4/Run'
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model' || w.name === 'model_version'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!modelWidget)
return '$0.2-0.3/Run (varies with model & texture quality)'
const model = String(modelWidget.value)
const textureQuality = String(textureQualityWidget?.value || 'standard')
// V2.5 pricing
if (model.includes('v2.5') || model.includes('2.5')) {
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
}
// V2.0 pricing
else if (model.includes('v2.0') || model.includes('2.0')) {
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
}
// V1.4 or legacy pricing
else {
return '$0.2/Run'
}
}
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model' || w.name === 'model_version'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!modelWidget)
return '$0.3-0.4/Run (varies with model & texture quality)'
const model = String(modelWidget.value)
const textureQuality = String(textureQualityWidget?.value || 'standard')
// V2.5 and V2.0 have same pricing structure
if (
model.includes('v2.5') ||
model.includes('2.5') ||
model.includes('v2.0') ||
model.includes('2.0')
) {
return textureQuality.includes('detailed') ? '$0.4/Run' : '$0.3/Run'
}
// V1.4 or legacy pricing (image_to_model is always $0.3)
else {
return '$0.3/Run'
}
}
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!textureQualityWidget) return '$0.1-0.2/Run (varies with quality)'
const textureQuality = String(textureQualityWidget.value)
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.0035/$0.0008 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0015/$0.0004 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o1-pro')) {
return '$0.15/$0.60 per 1K tokens'
} else if (model.includes('o1')) {
return '$0.015/$0.06 per 1K tokens'
} else if (model.includes('o3-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o3')) {
return '$0.01/$0.04 per 1K tokens'
} else if (model.includes('gpt-4o')) {
return '$0.0025/$0.01 per 1K tokens'
} else if (model.includes('gpt-4.1-nano')) {
return '$0.0001/$0.0004 per 1K tokens'
} else if (model.includes('gpt-4.1-mini')) {
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
}
return 'Token-based'
}
}
}
@@ -1263,19 +1075,7 @@ export const useNodePricing = () => {
RecraftGenerateVectorImageNode: ['n'],
MoonvalleyTxt2VideoNode: ['length'],
MoonvalleyImg2VideoNode: ['length'],
MoonvalleyVideo2VideoNode: ['length'],
// Runway nodes
RunwayImageToVideoNodeGen3a: ['duration'],
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['model', 'model_version', 'texture_quality'],
TripoImageToModelNode: ['model', 'model_version', 'texture_quality'],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
// OpenAI nodes
OpenAIChatNode: ['model']
MoonvalleyVideo2VideoNode: ['length']
}
return widgetMap[nodeType] || []
}

View File

@@ -59,7 +59,7 @@ export const useBrowserTabTitle = () => {
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
executionStore.activePrompt?.workflow?.changeTracker?.activeState?.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'

View File

@@ -171,11 +171,7 @@ export function useCoreCommands(): ComfyCommand[] {
confirm('Clear workflow?')
) {
app.clean()
if (app.canvas.subgraph) {
app.canvas.subgraph.clear()
} else {
app.graph.clear()
}
app.graph.clear()
api.dispatchCustomEvent('graphCleared')
}
}
@@ -317,19 +313,6 @@ export function useCoreCommands(): ComfyCommand[] {
}
})()
},
{
id: 'Comfy.Canvas.ToggleMinimap',
icon: 'pi pi-map',
label: 'Canvas Toggle Minimap',
versionAdded: '1.24.1',
function: async () => {
const settingStore = useSettingStore()
await settingStore.set(
'Comfy.Minimap.Visible',
!settingStore.get('Comfy.Minimap.Visible')
)
}
},
{
id: 'Comfy.QueuePrompt',
icon: 'pi pi-play',

View File

@@ -124,12 +124,9 @@ export const useLitegraphSettings = () => {
})
watchEffect(() => {
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
| 'standard'
| 'legacy'
LiteGraph.canvasNavigationMode = navigationMode
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
LiteGraph.macTrackpadGestures = settingStore.get(
'LiteGraph.Pointer.TrackpadGestures'
)
})
watchEffect(() => {

View File

@@ -1,685 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
}
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const minimapRef = ref<any>(null)
const visible = ref(true)
const initialized = ref(false)
const bounds = ref({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
const scale = ref(1)
const isDragging = ref(false)
const viewportTransform = ref({ x: 0, y: 0, width: 0, height: 0 })
const needsFullRedraw = ref(true)
const needsBoundsUpdate = ref(true)
const lastNodeCount = ref(0)
const nodeStatesCache = new Map<NodeId, string>()
const linksCache = ref<string>('')
const updateFlags = ref({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const width = 250
const height = 200
const nodeColor = '#0B8CE999'
const linkColor = '#F99614'
const slotColor = '#F99614'
const viewportColor = '#FFF'
const backgroundColor = '#15161C'
const borderColor = '#333'
const containerRect = ref({
left: 0,
top: 0,
width: width,
height: height
})
const canvasDimensions = ref({
width: 0,
height: 0
})
const updateContainerRect = () => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
containerRect.value = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
}
}
const updateCanvasDimensions = () => {
const c = canvas.value
if (!c) return
const canvasEl = c.canvas
const dpr = window.devicePixelRatio || 1
canvasDimensions.value = {
width: canvasEl.clientWidth || canvasEl.width / dpr,
height: canvasEl.clientHeight || canvasEl.height / dpr
}
}
const canvas = computed(() => canvasStore.canvas)
const graph = computed(() => canvas.value?.graph)
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: backgroundColor,
border: `1px solid ${borderColor}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
height: `${viewportTransform.value.height}px`,
border: `2px solid ${viewportColor}`,
backgroundColor: `${viewportColor}33`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
pointerEvents: 'none' as const
}))
const calculateGraphBounds = () => {
const g = graph.value
if (!g?._nodes || g._nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of g._nodes) {
minX = Math.min(minX, node.pos[0])
minY = Math.min(minY, node.pos[1])
maxX = Math.max(maxX, node.pos[0] + node.size[0])
maxY = Math.max(maxY, node.pos[1] + node.size[1])
}
let currentWidth = maxX - minX
let currentHeight = maxY - minY
// Enforce minimum viewport dimensions for better visualization
const minViewportWidth = 2500
const minViewportHeight = 2000
if (currentWidth < minViewportWidth) {
const padding = (minViewportWidth - currentWidth) / 2
minX -= padding
maxX += padding
currentWidth = minViewportWidth
}
if (currentHeight < minViewportHeight) {
const padding = (minViewportHeight - currentHeight) / 2
minY -= padding
maxY += padding
currentHeight = minViewportHeight
}
return {
minX,
minY,
maxX,
maxY,
width: currentWidth,
height: currentHeight
}
}
const calculateScale = () => {
if (bounds.value.width === 0 || bounds.value.height === 0) {
return 1
}
const scaleX = width / bounds.value.width
const scaleY = height / bounds.value.height
// Apply 0.9 factor to provide padding/gap between nodes and minimap borders
return Math.min(scaleX, scaleY) * 0.9
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) return
for (const node of g._nodes) {
const x = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
// Render solid node blocks
ctx.fillStyle = nodeColor
ctx.fillRect(x, y, w, h)
}
}
const renderConnections = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g) return
ctx.strokeStyle = linkColor
ctx.lineWidth = 1.4
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
x2: number
y2: number
}> = []
for (const node of g._nodes) {
if (!node.outputs) continue
const x1 = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
const y1 = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
for (const output of node.outputs) {
if (!output.links) continue
for (const linkId of output.links) {
const link = g.links[linkId]
if (!link) continue
const targetNode = g.getNodeById(link.target_id)
if (!targetNode) continue
const x2 =
(targetNode.pos[0] - bounds.value.minX) * scale.value + offsetX
const y2 =
(targetNode.pos[1] - bounds.value.minY) * scale.value + offsetY
const outputX = x1 + node.size[0] * scale.value
const outputY = y1 + node.size[1] * scale.value * 0.2
const inputX = x2
const inputY = y2 + targetNode.size[1] * scale.value * 0.2
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
}
}
// Render connection slots on top
ctx.fillStyle = slotColor
for (const conn of connections) {
// Output slot
ctx.beginPath()
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
ctx.fill()
// Input slot
ctx.beginPath()
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
ctx.fill()
}
}
const renderMinimap = () => {
if (!canvasRef.value || !graph.value) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) return
// Fast path for 0 nodes - just show background
if (!graph.value._nodes || graph.value._nodes.length === 0) {
ctx.clearRect(0, 0, width, height)
return
}
const needsRedraw =
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
if (needsRedraw) {
ctx.clearRect(0, 0, width, height)
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
renderNodes(ctx, offsetX, offsetY)
renderConnections(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
updateFlags.value.connections = false
}
}
const updateViewport = () => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
const worldX = -ds.offset[0]
const worldY = -ds.offset[1]
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
updateFlags.value.viewport = false
}
const updateMinimap = () => {
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
bounds.value = calculateGraphBounds()
scale.value = calculateScale()
needsBoundsUpdate.value = false
updateFlags.value.bounds = false
needsFullRedraw.value = true
}
if (
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
) {
renderMinimap()
}
}
const checkForChanges = useThrottleFn(() => {
const g = graph.value
if (!g) return
let structureChanged = false
let positionChanged = false
let connectionChanged = false
if (g._nodes.length !== lastNodeCount.value) {
structureChanged = true
lastNodeCount.value = g._nodes.length
}
for (const node of g._nodes) {
const key = node.id
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
if (nodeStatesCache.get(key) !== currentState) {
positionChanged = true
nodeStatesCache.set(key, currentState)
}
}
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
const currentNodeIds = new Set(g._nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
structureChanged = true
}
}
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
}
if (connectionChanged) {
updateFlags.value.connections = true
}
if (structureChanged || positionChanged || connectionChanged) {
updateMinimap()
}
}, 500)
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
useRafFn(
async () => {
if (visible.value) {
await checkForChanges()
}
},
{ immediate: false }
)
const { startSync: startViewportSync, stopSync: stopViewportSync } =
useCanvasTransformSync(updateViewport, { autoStart: false })
const handleMouseDown = (e: MouseEvent) => {
isDragging.value = true
updateContainerRect()
handleMouseMove(e)
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value || !canvasRef.value || !canvas.value) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
centerViewOn(worldX, worldY)
}
const handleMouseUp = () => {
isDragging.value = false
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const c = canvas.value
if (!c) return
if (
containerRect.value.left === 0 &&
containerRect.value.top === 0 &&
containerRef.value
) {
updateContainerRect()
}
const ds = c.ds
const delta = e.deltaY > 0 ? 0.9 : 1.1
const newScale = ds.scale * delta
const MIN_SCALE = 0.1
const MAX_SCALE = 10
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
ds.scale = newScale
centerViewOn(worldX, worldY)
}
const centerViewOn = (worldX: number, worldY: number) => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
ds.offset[0] = -(worldX - viewportWidth / 2)
ds.offset[1] = -(worldY - viewportHeight / 2)
updateFlags.value.viewport = true
c.setDirty(true, true)
}
let originalCallbacks: GraphCallbacks = {}
const handleGraphChanged = useThrottleFn(() => {
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}, 500)
const setupEventListeners = () => {
const g = graph.value
if (!g) return
originalCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
g.onNodeAdded = function (node) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChanged()
}
g.onNodeRemoved = function (node) {
originalCallbacks.onNodeRemoved?.call(this, node)
nodeStatesCache.delete(node.id)
void handleGraphChanged()
}
g.onConnectionChange = function (node) {
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChanged()
}
}
const cleanupEventListeners = () => {
const g = graph.value
if (!g) return
if (originalCallbacks.onNodeAdded !== undefined) {
g.onNodeAdded = originalCallbacks.onNodeAdded
}
if (originalCallbacks.onNodeRemoved !== undefined) {
g.onNodeRemoved = originalCallbacks.onNodeRemoved
}
if (originalCallbacks.onConnectionChange !== undefined) {
g.onConnectionChange = originalCallbacks.onConnectionChange
}
}
const init = async () => {
if (initialized.value) return
visible.value = settingStore.get('Comfy.Minimap.Visible')
if (canvas.value && graph.value) {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChanged)
if (containerRef.value) {
updateContainerRect()
}
updateCanvasDimensions()
window.addEventListener('resize', updateContainerRect)
window.addEventListener('scroll', updateContainerRect)
window.addEventListener('resize', updateCanvasDimensions)
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
updateMinimap()
updateViewport()
if (visible.value) {
resumeChangeDetection()
startViewportSync()
}
initialized.value = true
}
}
const destroy = () => {
pauseChangeDetection()
stopViewportSync()
cleanupEventListeners()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
nodeStatesCache.clear()
initialized.value = false
}
watch(
canvas,
async (newCanvas, oldCanvas) => {
if (oldCanvas) {
cleanupEventListeners()
pauseChangeDetection()
stopViewportSync()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
}
if (newCanvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {
updateContainerRect()
}
updateCanvasDimensions()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
await nextTick()
updateMinimap()
updateViewport()
resumeChangeDetection()
startViewportSync()
} else {
pauseChangeDetection()
stopViewportSync()
}
})
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)
}
const setMinimapRef = (ref: any) => {
minimapRef.value = ref
}
return {
visible: computed(() => visible.value),
initialized: computed(() => initialized.value),
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,
height,
init,
destroy,
toggle,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel,
setMinimapRef
}
}

View File

@@ -1,4 +1,5 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { computed, ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
@@ -8,11 +9,12 @@ interface RefreshableItem {
refresh: () => Promise<void> | void
}
const isRefreshableWidget = (widget: unknown): widget is RefreshableItem =>
widget != null &&
typeof widget === 'object' &&
'refresh' in widget &&
typeof widget.refresh === 'function'
type RefreshableWidget = IBaseWidget & RefreshableItem
const isRefreshableWidget = (
widget: IBaseWidget
): widget is RefreshableWidget =>
'refresh' in widget && typeof widget.refresh === 'function'
/**
* Tracks selected nodes and their refreshable widgets
@@ -25,17 +27,10 @@ export const useRefreshableSelection = () => {
selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode)
})
const refreshableWidgets = computed<RefreshableItem[]>(() =>
selectedNodes.value.flatMap((node) => {
if (!node.widgets) return []
const items: RefreshableItem[] = []
for (const widget of node.widgets) {
if (isRefreshableWidget(widget)) {
items.push(widget)
}
}
return items
})
const refreshableWidgets = computed(() =>
selectedNodes.value.flatMap(
(node) => node.widgets?.filter(isRefreshableWidget) ?? []
)
)
const isRefreshable = computed(() => refreshableWidgets.value.length > 0)

View File

@@ -3,23 +3,14 @@ import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: ChatHistoryCustomProps
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
@@ -29,7 +20,7 @@ export const useChatHistoryWidget = (
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
ChatHistoryCustomProps
InstanceType<typeof ChatHistoryWidget>['$props']
>({
node,
name: inputSpec.name,

View File

@@ -3,18 +3,9 @@ import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
@@ -27,15 +18,12 @@ export const useTextPreviewWidget = (
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
const widget = new ComponentWidgetImpl<string | object>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
componentProps: {
nodeId: node.id
},
options: {

View File

@@ -8,8 +8,6 @@ import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
function addMultilineWidget(
node: LGraphNode,
name: string,
@@ -56,55 +54,38 @@ function addMultilineWidget(
}
})
/** Timer reference. `null` when the timer completes. */
let ignoreEventsTimer: ReturnType<typeof setTimeout> | null = null
/** Total number of events ignored since the timer started. */
let ignoredEvents = 0
// Pass wheel events to the canvas when appropriate
inputEl.addEventListener('wheel', (event: WheelEvent) => {
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
if (!Object.is(event.deltaX, -0)) return
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// If the textarea has focus, require more effort to activate pass-through
const multiplier = document.activeElement === inputEl ? 2 : 1
const maxScrollHeight = inputEl.scrollHeight - inputEl.clientHeight
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
if (
(event.deltaY < 0 && inputEl.scrollTop === 0) ||
(event.deltaY > 0 && inputEl.scrollTop === maxScrollHeight)
) {
// Attempting to scroll past the end of the textarea
if (!ignoreEventsTimer || ignoredEvents > 25 * multiplier) {
app.canvas.processMouseWheel(event)
} else {
ignoredEvents++
}
} else if (event.deltaY !== 0) {
// Start timer whenever a successful scroll occurs
ignoredEvents = 0
if (ignoreEventsTimer) clearTimeout(ignoreEventsTimer)
ignoreEventsTimer = setTimeout(() => {
ignoreEventsTimer = null
}, 800 * multiplier)
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
return widget

View File

@@ -181,12 +181,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
},
{
combo: {
key: 'm',
alt: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
}
]

View File

@@ -35,10 +35,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Action on link release (No modifier)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
defaultsByInstallVersion: {
'1.24.1': LinkReleaseTriggerAction.SEARCH_BOX
}
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU
},
{
id: 'Comfy.LinkRelease.ActionShift',
@@ -46,10 +43,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Action on link release (Shift)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX,
defaultsByInstallVersion: {
'1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU
}
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX
},
{
id: 'Comfy.NodeSearchBoxImpl.NodePreview',
@@ -511,6 +505,15 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: [] as string[],
versionAdded: '1.3.11'
},
{
id: 'Comfy.Validation.NodeDefs',
name: 'Validate node definitions (slow)',
type: 'boolean',
tooltip:
'Recommended for node developers. This will validate all node definitions on startup.',
defaultValue: false,
versionAdded: '1.3.14'
},
{
id: 'Comfy.LinkRenderMode',
category: ['LiteGraph', 'Graph', 'LinkRenderMode'],
@@ -782,21 +785,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 0.6,
versionAdded: '1.9.1'
},
{
id: 'Comfy.Canvas.NavigationMode',
category: ['LiteGraph', 'Canvas', 'CanvasNavigationMode'],
name: 'Canvas Navigation Mode',
defaultValue: 'legacy',
type: 'combo',
options: [
{ value: 'standard', text: 'Standard (New)' },
{ value: 'legacy', text: 'Left-Click Pan (Legacy)' }
],
versionAdded: '1.25.0',
defaultsByInstallVersion: {
'1.25.0': 'standard'
}
},
{
id: 'Comfy.Canvas.SelectionToolbox',
category: ['LiteGraph', 'Canvas', 'SelectionToolbox'],
@@ -824,13 +812,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.15.12'
},
{
id: 'Comfy.Minimap.Visible',
name: 'Display minimap on canvas',
type: 'hidden',
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',
@@ -874,6 +855,17 @@ export const CORE_SETTINGS: SettingParams[] = [
versionAdded: '1.20.4',
versionModified: '1.20.5'
},
{
id: 'LiteGraph.Pointer.TrackpadGestures',
category: ['LiteGraph', 'Pointer', 'Trackpad Gestures'],
experimental: true,
name: 'Enable trackpad gestures',
tooltip:
'This setting enables trackpad mode for the canvas, allowing pinch-to-zoom and panning with two fingers.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.19.1'
},
// Release data stored in settings
{
id: 'Comfy.Release.Version',

View File

@@ -6,8 +6,8 @@
* All supported image formats that can contain workflow data
*/
export const IMAGE_WORKFLOW_FORMATS = {
extensions: ['.png', '.webp', '.svg', '.avif'],
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml', 'image/avif']
extensions: ['.png', '.webp', '.svg'],
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml']
}
/**

View File

@@ -21,6 +21,8 @@ async function handleModelUpload(files: FileList, node: any) {
(w: any) => w.name === 'model_file'
) as IStringWidget
node.properties['Texture'] = undefined
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
@@ -213,6 +215,8 @@ useExtensionService().registerExtension({
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = ''
node.properties['Texture'] = undefined
}
})

View File

@@ -160,6 +160,12 @@ class Load3DConfiguration {
this.load3d.setEdgeThreshold(edgeThreshold)
const texturePath = this.load3d.loadNodeProperty('Texture', null)
if (texturePath) {
await this.load3d.applyTexture(texturePath)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)

View File

@@ -293,6 +293,23 @@ class Load3d {
}
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.modelManager.currentModel) {
throw new Error('No model to apply texture to')
}
this.eventManager.emitEvent('textureLoadingStart', null)
try {
await this.modelManager.applyTexture(texturePath)
} catch (error) {
console.error('Error applying texture:', error)
throw error
} finally {
this.eventManager.emitEvent('textureLoadingEnd', null)
}
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)

View File

@@ -5,6 +5,7 @@ import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeome
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
import Load3dUtils from './Load3dUtils'
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
@@ -134,6 +135,66 @@ export class ModelManager implements ModelManagerInterface {
})
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.currentModel) {
throw new Error('No model available to apply texture to')
}
if (this.appliedTexture) {
this.appliedTexture.dispose()
}
try {
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(texturePath)
)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl
}
this.appliedTexture = await new Promise<THREE.Texture>(
(resolve, reject) => {
this.textureLoader.load(
imageUrl,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
resolve(texture)
},
undefined,
(error) => reject(error)
)
}
)
if (this.materialMode === 'original') {
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
const material = new THREE.MeshStandardMaterial({
map: this.appliedTexture,
metalness: 0.1,
roughness: 0.8,
side: THREE.DoubleSide
})
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = material
}
})
}
return Promise.resolve()
} catch (error) {
console.error('Error applying texture:', error)
return Promise.reject(error)
}
}
disposeLineartModel(): void {
this.disposeEdgesModel()
this.disposeShadowModel()

View File

@@ -1,29 +0,0 @@
export interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
export const imageLayerFilenamesByTimestamp = (
timestamp: number
): ImageLayerFilenames => ({
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
})
export const imageLayerFilenamesIfApplicable = (
inputImageFilename: string
): ImageLayerFilenames | undefined => {
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
paintedMaskedImagePrefix
)
if (!isPaintedMaskedImageFilename) return undefined
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
const timestamp = parseInt(suffix.split('.')[0], 10)
return imageLayerFilenamesByTimestamp(timestamp)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,6 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type {
IBaseWidget,
IStringWidget
} from '@comfyorg/litegraph/dist/types/widgets'
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
@@ -13,10 +8,7 @@ import { t } from '@/i18n'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { useToastStore } from '@/stores/toastStore'
import { NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
@@ -145,27 +137,14 @@ app.registerExtension({
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
audioUIWidget.onRemove = useChainCallback(
audioUIWidget.onRemove,
() => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
}
)
return { widget: audioUIWidget }
}
}
},
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
const node = app.graph.getNodeById(nodeId)
if ('audio' in output) {
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
if (!node) continue
// @ts-expect-error fixme ts strict error
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
@@ -262,167 +241,3 @@ app.registerExtension({
}
}
})
app.registerExtension({
name: 'Comfy.RecordAudio',
getCustomWidgets() {
return {
AUDIO_RECORD(node, inputName: string) {
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
let audioChunks: Blob[] = []
let currentStream: MediaStream | null = null
let recordWidget: IBaseWidget | null = null
let stopPromise: Promise<void> | null = null
let stopResolve: (() => void) | null = null
audioUIWidget.serializeValue = async () => {
if (isRecording && mediaRecorder) {
stopPromise = new Promise((resolve) => {
stopResolve = resolve
})
mediaRecorder.stop()
await stopPromise
}
const audioSrc = audioUIWidget.element.src
if (!audioSrc) {
useToastStore().addAlert(t('g.noAudioRecorded'))
return ''
}
const blob = await fetch(audioSrc).then((r) => r.blob())
return await useAudioService().convertBlobToFileAndSubmit(blob)
}
recordWidget = node.addWidget(
'button',
inputName,
'',
async () => {
if (!isRecording) {
try {
currentStream = await navigator.mediaDevices.getUserMedia({
audio: true
})
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
mimeType: 'audio/wav'
}) as unknown as MediaRecorder
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data)
}
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
useAudioService().stopAllTracks(currentStream)
if (
audioUIWidget.element.src &&
audioUIWidget.element.src.startsWith('blob:')
) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.onerror = (event) => {
console.error('MediaRecorder error:', event)
useAudioService().stopAllTracks(currentStream)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.start()
isRecording = true
if (recordWidget) {
recordWidget.label = t('g.stopRecording')
}
} catch (err) {
console.error('Error accessing microphone:', err)
useToastStore().addAlert(t('g.micPermissionDenied'))
if (mediaRecorder) {
try {
mediaRecorder.stop()
} catch {}
}
useAudioService().stopAllTracks(currentStream)
currentStream = null
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
}
} else if (mediaRecorder && isRecording) {
mediaRecorder.stop()
}
},
{ serialize: false }
)
recordWidget.label = t('g.startRecording')
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {
if (isRecording && mediaRecorder) {
mediaRecorder.stop()
}
useAudioService().stopAllTracks(currentStream)
if (audioUIWidget.element.src?.startsWith('blob:')) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
originalOnRemoved?.call(this)
}
return { widget: recordWidget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'RecordAudio') return
await useAudioService().registerWavEncoder()
}
})

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