mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
Compare commits
19 Commits
feat/updat
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0190ec6392 | ||
|
|
1eadf80fec | ||
|
|
f1aba23ee1 | ||
|
|
934f2674e9 | ||
|
|
907662a42b | ||
|
|
378ac4880c | ||
|
|
4c6e7f106b | ||
|
|
dc395f5d6d | ||
|
|
61c9341450 | ||
|
|
d96d8cb9a9 | ||
|
|
d779df5f64 | ||
|
|
3e801581b5 | ||
|
|
47e1808861 | ||
|
|
eae4b954d0 | ||
|
|
baea47c493 | ||
|
|
8673e0e6c4 | ||
|
|
b125e0aa3a | ||
|
|
0cdb56e023 | ||
|
|
3861e0068f |
157
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
157
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,99 +1,106 @@
|
||||
name: Bug Report
|
||||
description: 'Something is not behaving as expected.'
|
||||
description: 'Report something that is not working correctly'
|
||||
title: '[Bug]: '
|
||||
labels: ['Potential Bug']
|
||||
type: Bug
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting a **Bug Report**, please ensure the following:
|
||||
|
||||
- **1:** You are running the latest version of ComfyUI.
|
||||
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
|
||||
|
||||
- type: checkboxes
|
||||
id: custom-nodes-test
|
||||
attributes:
|
||||
label: Custom Node Testing
|
||||
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
|
||||
- label: I am running the latest version of ComfyUI
|
||||
required: true
|
||||
- label: I have searched existing issues to make sure this isn't a duplicate
|
||||
required: true
|
||||
- label: I have tested with all custom nodes disabled ([see how](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled))
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Frontend Version
|
||||
description: |
|
||||
What is the frontend version you are using? You can check this in the settings dialog.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Click to show where to find the version</summary>
|
||||
|
||||
Open the setting by clicking the cog icon in the bottom-left of the screen, then click `About`.
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: 'What you expected to happen.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.'
|
||||
label: What happened?
|
||||
description: A clear and concise description of the bug. Include screenshots or videos if helpful.
|
||||
placeholder: |
|
||||
Example: "When I connect a VAE Decode node to a KSampler, the connection line appears but the workflow fails to execute with an error message..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Debug Logs
|
||||
description: 'Please copy the output from your terminal logs here.'
|
||||
render: powershell
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Browser Logs
|
||||
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Setting JSON
|
||||
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
|
||||
description: How can we reproduce this issue? Please attach your workflow (JSON or PNG).
|
||||
placeholder: |
|
||||
1. Add a KSampler node
|
||||
2. Connect it to...
|
||||
3. Click Queue Prompt
|
||||
4. See error
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
id: severity
|
||||
attributes:
|
||||
label: What browsers do you use to access the UI ?
|
||||
multiple: true
|
||||
label: How is this affecting you?
|
||||
options:
|
||||
- Mozilla Firefox
|
||||
- Google Chrome
|
||||
- Brave
|
||||
- Apple Safari
|
||||
- Microsoft Edge
|
||||
- Android
|
||||
- iOS
|
||||
- Other
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Other Information
|
||||
description: 'Any other context, details, or screenshots that might help solve the issue.'
|
||||
placeholder: 'Add any other relevant information here...'
|
||||
- Crashes ComfyUI completely
|
||||
- Workflow won't execute
|
||||
- Feature doesn't work as expected
|
||||
- Visual/UI issue only
|
||||
- Minor inconvenience
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: ComfyUI Frontend Version
|
||||
description: Found in Settings > About (e.g., "1.3.45")
|
||||
placeholder: "1.3.45"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: Which browser are you using?
|
||||
options:
|
||||
- Chrome/Chromium
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional Information (Optional)
|
||||
*The following fields help us debug complex issues but are not required for most bug reports.*
|
||||
|
||||
- type: textarea
|
||||
id: console-errors
|
||||
attributes:
|
||||
label: Console Errors
|
||||
description: If you see red error messages in the browser console (F12), paste them here
|
||||
render: javascript
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: If relevant, paste any terminal/server logs here
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other information that might help (OS, GPU, specific nodes involved, etc.)
|
||||
|
||||
74
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
74
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: '[Feature Request]: '
|
||||
description: Report a problem or limitation you're experiencing
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
type: Feature
|
||||
|
||||
@@ -8,34 +8,74 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit.
|
||||
description: Please search to see if an issue already exists for the problem you're experiencing, and that it's not addressed in a recent build/commit.
|
||||
options:
|
||||
- label: I have searched the existing issues and checked the recent builds/commits
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
|
||||
*Please focus on describing the problem you're experiencing rather than proposing specific solutions. This helps us design the best possible solution for you and other users.*
|
||||
- type: textarea
|
||||
id: feature
|
||||
id: problem
|
||||
attributes:
|
||||
label: What would your feature do ?
|
||||
description: Tell us about your feature in a very clear and simple way, and what problem it would solve
|
||||
label: What problem are you experiencing?
|
||||
description: Describe the issue or limitation you're facing in your workflow
|
||||
placeholder: |
|
||||
Example: "I frequently lose work when switching between different projects because there's no way to save my current workspace state"
|
||||
NOT: "Add a save button that exports the workspace"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: workflow
|
||||
id: context
|
||||
attributes:
|
||||
label: Proposed workflow
|
||||
description: Please provide us with step by step information on how you'd like the feature to be accessed and used
|
||||
value: |
|
||||
1. Go to ....
|
||||
2. Press ....
|
||||
3. ...
|
||||
label: When does this problem occur?
|
||||
description: Describe the specific situations or workflows where you encounter this issue
|
||||
placeholder: |
|
||||
- When working with large node graphs...
|
||||
- During batch processing workflows...
|
||||
- While collaborating with team members...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
label: How often do you encounter this problem?
|
||||
options:
|
||||
- Multiple times per day
|
||||
- Daily
|
||||
- Several times per week
|
||||
- Weekly
|
||||
- Occasionally
|
||||
- Rarely
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: impact
|
||||
attributes:
|
||||
label: How much does this problem affect your workflow?
|
||||
description: Help us understand the severity of this issue for you
|
||||
options:
|
||||
- Blocks me from completing tasks
|
||||
- Significantly slows down my work
|
||||
- Causes moderate inconvenience
|
||||
- Minor annoyance
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: misc
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
label: Current workarounds
|
||||
description: How do you currently deal with this problem, if at all?
|
||||
placeholder: |
|
||||
Example: "I manually export and reimport nodes between projects, which takes 10-15 minutes each time"
|
||||
- type: textarea
|
||||
id: ideas
|
||||
attributes:
|
||||
label: Ideas for solutions (Optional)
|
||||
description: If you have thoughts on potential solutions, feel free to share them here. However, we'll explore all possible options to find the best approach.
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context, screenshots, or examples that help illustrate the problem.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
13
.husky/pre-commit
Normal file → Executable file
13
.husky/pre-commit
Normal file → Executable file
@@ -1,9 +1,4 @@
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
npx.cmd lint-staged
|
||||
# Check for unused i18n keys in staged files
|
||||
npx.cmd tsx scripts/check-unused-i18n-keys.ts
|
||||
else
|
||||
npx lint-staged
|
||||
# Check for unused i18n keys in staged files
|
||||
npx tsx scripts/check-unused-i18n-keys.ts
|
||||
fi
|
||||
#!/usr/bin/env bash
|
||||
|
||||
npx lint-staged
|
||||
npx tsx scripts/check-unused-i18n-keys.ts
|
||||
@@ -3,6 +3,10 @@
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@executeautomation/playwright-mcp-server"]
|
||||
},
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
108
CLAUDE.md
108
CLAUDE.md
@@ -1,58 +1,50 @@
|
||||
- use `npm run` to see what commands are available
|
||||
- For component communication, prefer Vue's event-based pattern (emit/@event-name) for state changes and notifications; use defineExpose with refs only for imperative operations that need direct control (like form.validate(), modal.open(), or editor.focus()); events promote loose coupling and are better for reusable components, while exposed methods are acceptable for tightly-coupled component pairs or when wrapping third-party libraries that require imperative APIs
|
||||
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
|
||||
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
|
||||
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
|
||||
- Never add lines to PR descriptions or commit messages that say "Generated with Claude Code"
|
||||
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
|
||||
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
|
||||
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
|
||||
- Prefer running single tests, and not the whole test suite, for performance
|
||||
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
|
||||
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
|
||||
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- The npm script to type check is called "typecheck" NOT "type check"
|
||||
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
|
||||
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
|
||||
- Never write css if you can accomplish the same thing with tailwind utility classes
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Do not define a `props` variable; instead, destructure props. Since vue 3.5, destructuring props does not strip them of reactivity.
|
||||
- Use Tailwind CSS for styling
|
||||
- Leverage VueUse functions for performance-enhancing styles
|
||||
- Use lodash for utility functions
|
||||
- Implement proper props and emits definitions
|
||||
- Utilize Vue 3's Teleport component when needed
|
||||
- Use Suspense for async components
|
||||
- Implement proper error handling
|
||||
- Follow Vue 3 style guide and naming conventions
|
||||
- IMPORTANT: Use vue-i18n for ALL user-facing strings - no hard-coded text in services/utilities. Place new translation entries in src/locales/en/main.json
|
||||
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
|
||||
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
* `Dropdown` → Use `Select` (import from 'primevue/select')
|
||||
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
|
||||
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
|
||||
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
|
||||
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
|
||||
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
* `TabMenu` → Use `Tabs` without panels
|
||||
* `Steps` → Use `Stepper` without panels
|
||||
* `InlineMessage` → Use `Message` component
|
||||
* Use `api.apiURL()` for all backend API calls and routes
|
||||
- Actual API endpoints like /prompt, /queue, /view, etc.
|
||||
- Image previews: `api.apiURL('/view?...')`
|
||||
- Any backend-generated content or dynamic routes
|
||||
* Use `api.fileURL()` for static files served from the public folder:
|
||||
- Templates: `api.fileURL('/templates/default.json')`
|
||||
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
|
||||
- Any static assets that exist in the public directory
|
||||
- When implementing code that outputs raw HTML (e.g., using v-html directive), always ensure dynamic content has been properly sanitized with DOMPurify or validated through trusted sources. Prefer Vue templates over v-html when possible.
|
||||
- For any async operations (API calls, timers, etc), implement cleanup/cancellation in component unmount to prevent memory leaks
|
||||
- Extract complex template conditionals into separate components or computed properties
|
||||
- Error messages should be actionable and user-friendly (e.g., "Failed to load data. Please refresh the page." instead of "Unknown error")
|
||||
# ComfyUI Frontend Project Guidelines
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- `npm run`: See all available commands
|
||||
- `npm run typecheck`: Type checking
|
||||
- `npm run lint`: Linting
|
||||
- `npm run format`: Prettier formatting
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Make code changes
|
||||
2. Run tests (see subdirectory CLAUDE.md files)
|
||||
3. Run typecheck, lint, format
|
||||
4. Check README updates
|
||||
5. Consider docs.comfy.org updates
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Use [prefix] format: [feat], [bugfix], [docs]
|
||||
- Add "Fixes #n" to PR descriptions
|
||||
- Never mention Claude/AI in commits
|
||||
|
||||
## External Resources
|
||||
|
||||
- PrimeVue docs: <https://primevue.org>
|
||||
- ComfyUI docs: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
- Clean, stable public APIs
|
||||
- Domain-driven design
|
||||
- Thousands of users and extensions
|
||||
- Prioritize clean interfaces that restrict extension access
|
||||
|
||||
## Repository Navigation
|
||||
|
||||
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
|
||||
- Prefer running single tests for performance
|
||||
- Use --help for unfamiliar CLI tools
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
When referencing Comfy-Org repos:
|
||||
|
||||
1. Check for local copy
|
||||
2. Use GitHub API for branches/PRs/metadata
|
||||
3. Curl GitHub website if needed
|
||||
|
||||
17
browser_tests/CLAUDE.md
Normal file
17
browser_tests/CLAUDE.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# E2E Testing Guidelines
|
||||
|
||||
## Browser Tests
|
||||
- Test user workflows
|
||||
- Use Playwright fixtures
|
||||
- Follow naming conventions
|
||||
|
||||
## Best Practices
|
||||
- Check assets/ for test data
|
||||
- Prefer specific selectors
|
||||
- Test across viewports
|
||||
|
||||
## Testing Process
|
||||
After code changes:
|
||||
1. Create browser tests as appropriate
|
||||
2. Run tests until passing
|
||||
3. Then run typecheck, lint, format
|
||||
182
browser_tests/assets/missing_nodes_in_subgraph.json
Normal file
182
browser_tests/assets/missing_nodes_in_subgraph.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"id": "test-missing-nodes-in-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-node",
|
||||
"pos": [400, 100],
|
||||
"size": [144, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input1",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "output1",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-node",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Node",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "input1",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 150,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "output1",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 520,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "MISSING_NODE_TYPE_IN_SUBGRAPH",
|
||||
"pos": [250, 180],
|
||||
"size": [200, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "output",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "MISSING_NODE_TYPE_IN_SUBGRAPH"
|
||||
},
|
||||
"widgets_values": ["some", "widget", "values"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -13,6 +13,21 @@ test.describe('Load workflow warning', () => {
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when loading a workflow with missing nodes in subgraphs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('missing_nodes_in_subgraph')
|
||||
|
||||
// Wait for the element with the .comfy-missing-nodes selector to be visible
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
|
||||
// Verify the missing node text includes subgraph context
|
||||
const warningText = await missingNodesWarning.textContent()
|
||||
expect(warningText).toContain('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
expect(warningText).toContain('in subgraph')
|
||||
})
|
||||
})
|
||||
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
@@ -369,7 +384,7 @@ test.describe('Signin dialog', () => {
|
||||
await textBox.press('Control+c')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].extensionManager.dialog.showSignInDialog()
|
||||
void window['app'].extensionManager.dialog.showSignInDialog()
|
||||
})
|
||||
|
||||
const input = comfyPage.page.locator('#comfy-org-sign-in-password')
|
||||
|
||||
471
package-lock.json
generated
471
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.2",
|
||||
"version": "1.25.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.2",
|
||||
"version": "1.25.4",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@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.20",
|
||||
"@comfyorg/litegraph": "^0.17.0",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"msw": "^2.10.4",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
@@ -970,6 +971,37 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cookie": "^0.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/statuses": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz",
|
||||
"integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"statuses": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/tough-cookie": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz",
|
||||
"integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/tough-cookie": "^4.0.5",
|
||||
"tough-cookie": "^4.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@comfyorg/comfyui-electron-types": {
|
||||
"version": "0.4.43",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.43.tgz",
|
||||
@@ -977,9 +1009,9 @@
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.16.20",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.20.tgz",
|
||||
"integrity": "sha512-8iUBhKYkr9qV6vWxC3C9Wea9K7iHwyDHxxN6OrhE9sySYfUA14XuNpVMaC8eVUaIm5KBOSmr/Q1J2XVHsHEISg==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.17.0.tgz",
|
||||
"integrity": "sha512-5Xu/G5NJFDucJontxXeXhzcaFuEXZznyg8u+joSjbe2G1991/tO7hYrPjXph2FLcovkSnEhG4ixdhyAwubGS3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -2454,6 +2486,160 @@
|
||||
"ink": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/confirm": {
|
||||
"version": "5.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz",
|
||||
"integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^10.1.15",
|
||||
"@inquirer/type": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core": {
|
||||
"version": "10.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz",
|
||||
"integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/figures": "^1.0.13",
|
||||
"@inquirer/type": "^3.0.8",
|
||||
"ansi-escapes": "^4.3.2",
|
||||
"cli-width": "^4.1.0",
|
||||
"mute-stream": "^2.0.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"wrap-ansi": "^6.2.0",
|
||||
"yoctocolors-cjs": "^2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/figures": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz",
|
||||
"integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/type": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz",
|
||||
"integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.3.tgz",
|
||||
@@ -3076,6 +3262,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.39.5",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.5.tgz",
|
||||
"integrity": "sha512-B9nHSJYtsv79uo7QdkZ/b/WoKm20IkVSmTc/WCKarmDtFwM0dRx2ouEniqwNkzCSLn3fydzKmnMzjtfdOWt3VQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/logger": "^0.3.0",
|
||||
"@open-draft/until": "^2.0.0",
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.3",
|
||||
"strict-event-emitter": "^0.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3118,6 +3322,31 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@open-draft/deferred-promise": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
|
||||
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@open-draft/logger": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
|
||||
"integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@open-draft/until": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -4500,6 +4729,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -4644,6 +4880,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/statuses": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.169.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz",
|
||||
@@ -4659,6 +4902,13 @@
|
||||
"meshoptimizer": "~0.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -5704,6 +5954,35 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes/node_modules/type-fest": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -6037,19 +6316,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/type-fest": {
|
||||
"version": "4.29.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.29.0.tgz",
|
||||
"integrity": "sha512-RPYt6dKyemXJe7I6oNstcH24myUGSReicxcHTvCLgzm4e0n8y05dGvcGB15/SoPRBmhlMthWQ9pvKyL81ko8nQ==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/widest-line": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
||||
@@ -6446,6 +6712,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
|
||||
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -6715,19 +6991,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/configstore/node_modules/type-fest": {
|
||||
"version": "4.29.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.29.0.tgz",
|
||||
"integrity": "sha512-RPYt6dKyemXJe7I6oNstcH24myUGSReicxcHTvCLgzm4e0n8y05dGvcGB15/SoPRBmhlMthWQ9pvKyL81ko8nQ==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/connect-history-api-fallback": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz",
|
||||
@@ -9190,6 +9453,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
"version": "16.11.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
|
||||
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
@@ -9282,6 +9555,13 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/headers-polyfill": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
|
||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
@@ -9955,6 +10235,13 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/is-node-process": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
|
||||
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-npm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz",
|
||||
@@ -12336,6 +12623,58 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/msw": {
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.10.4.tgz",
|
||||
"integrity": "sha512-6R1or/qyele7q3RyPwNuvc0IxO8L8/Aim6Sz5ncXEgcWUNxSKE+udriTOWHtpMwmfkLYlacA2y7TIx4cL5lgHA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bundled-es-modules/cookie": "^2.0.1",
|
||||
"@bundled-es-modules/statuses": "^1.0.1",
|
||||
"@bundled-es-modules/tough-cookie": "^0.1.6",
|
||||
"@inquirer/confirm": "^5.0.0",
|
||||
"@mswjs/interceptors": "^0.39.1",
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/until": "^2.1.0",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/statuses": "^2.0.4",
|
||||
"graphql": "^16.8.1",
|
||||
"headers-polyfill": "^4.0.2",
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.3",
|
||||
"path-to-regexp": "^6.3.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"type-fest": "^4.26.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"msw": "cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mswjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">= 4.8.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/msw/node_modules/path-to-regexp": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -12366,6 +12705,16 @@
|
||||
"mustache": "bin/mustache"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -12748,6 +13097,13 @@
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/outvariant": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
|
||||
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
@@ -13714,9 +14070,7 @@
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -13787,9 +14141,7 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
@@ -14300,9 +14652,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
@@ -14908,6 +15258,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strict-event-emitter": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
|
||||
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -15430,8 +15787,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
@@ -15986,6 +16341,19 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
@@ -16249,8 +16617,6 @@
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -16501,8 +16867,6 @@
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
@@ -17940,6 +18304,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors-cjs": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
|
||||
"integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-wasm-web": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.2",
|
||||
"version": "1.25.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -57,6 +57,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"msw": "^2.10.4",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
@@ -78,7 +79,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.20",
|
||||
"@comfyorg/litegraph": "^0.17.0",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
44
src/CLAUDE.md
Normal file
44
src/CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Source Code Guidelines
|
||||
|
||||
## Service Layer
|
||||
|
||||
### API Calls
|
||||
|
||||
- Use `api.apiURL()` for backend endpoints
|
||||
- Use `api.fileURL()` for static files
|
||||
- Examples:
|
||||
- Backend: `api.apiURL('/prompt')`
|
||||
- Static: `api.fileURL('/templates/default.json')`
|
||||
|
||||
### Error Handling
|
||||
|
||||
- User-friendly and actionable messages
|
||||
- Proper error propagation
|
||||
|
||||
### Security
|
||||
|
||||
- Sanitize HTML with DOMPurify
|
||||
- Validate trusted sources
|
||||
- Never log secrets
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
### Store Design
|
||||
|
||||
- Follow domain-driven design
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use TypeScript for type safety
|
||||
- Implement proper error handling
|
||||
- Clean up subscriptions
|
||||
- Avoid @ts-expect-error
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use lodash for utility functions
|
||||
- Implement proper TypeScript types
|
||||
- Follow Vue 3 composition API style guide
|
||||
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
|
||||
45
src/components/CLAUDE.md
Normal file
45
src/components/CLAUDE.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Component Guidelines
|
||||
|
||||
## Vue 3 Composition API
|
||||
|
||||
- Use setup() function
|
||||
- Destructure props (Vue 3.5 style)
|
||||
- Use ref/reactive for state
|
||||
- Implement computed() for derived state
|
||||
- Use provide/inject for dependency injection
|
||||
|
||||
## Component Communication
|
||||
|
||||
- Prefer `emit/@event-name` for state changes
|
||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||
- Events promote loose coupling
|
||||
|
||||
## UI Framework
|
||||
|
||||
- Deprecated PrimeVue component replacements:
|
||||
- Dropdown → Select
|
||||
- OverlayPanel → Popover
|
||||
- Calendar → DatePicker
|
||||
- InputSwitch → ToggleSwitch
|
||||
- Sidebar → Drawer
|
||||
- Chips → AutoComplete with multiple enabled
|
||||
- TabMenu → Tabs without panels
|
||||
- Steps → Stepper without panels
|
||||
- InlineMessage → Message
|
||||
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS only (no custom CSS)
|
||||
- Dark theme: use "dark-theme:" prefix
|
||||
- For common operations, try to use existing VueUse composables that automatically handle effect scope
|
||||
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
|
||||
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Extract complex conditionals to computed
|
||||
- Implement cleanup for async operations
|
||||
- Use vue-i18n for ALL UI strings
|
||||
- Use lifecycle hooks: onMounted, onUpdated
|
||||
- Use Teleport/Suspense when needed
|
||||
- Proper props and emits definitions
|
||||
@@ -17,26 +17,28 @@ import { createBounds } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
|
||||
const visible = ref(false)
|
||||
const showBorder = ref(false)
|
||||
|
||||
const positionSelectionOverlay = () => {
|
||||
const { selectedItems } = canvasStore.getCanvas()
|
||||
showBorder.value = selectedItems.size > 1
|
||||
const selectableItems = getSelectableItems()
|
||||
showBorder.value = selectableItems.size > 1
|
||||
|
||||
if (!selectedItems.size) {
|
||||
if (!selectableItems.size) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectedItems)
|
||||
const bounds = createBounds(selectableItems)
|
||||
if (bounds) {
|
||||
updatePosition({
|
||||
pos: [bounds[0], bounds[1]],
|
||||
@@ -45,7 +47,6 @@ const positionSelectionOverlay = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Register listener on canvas creation.
|
||||
whenever(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
() => {
|
||||
|
||||
162
src/composables/canvas/useSelectedLiteGraphItems.ts
Normal file
162
src/composables/canvas/useSelectedLiteGraphItems.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
Positionable,
|
||||
Reroute
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import {
|
||||
collectFromNodes,
|
||||
traverseNodesDepthFirst
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling selected LiteGraph items filtering and operations.
|
||||
* This provides utilities for working with selected items on the canvas,
|
||||
* including filtering out items that should not be included in selection operations.
|
||||
*/
|
||||
export function useSelectedLiteGraphItems() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
/**
|
||||
* Items that should not show in the selection overlay are ignored.
|
||||
* @param item - The item to check.
|
||||
* @returns True if the item should be ignored, false otherwise.
|
||||
*/
|
||||
const isIgnoredItem = (item: Positionable): boolean => {
|
||||
return item instanceof Reroute
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out items that should not show in the selection overlay.
|
||||
* @param items - The Set of items to filter.
|
||||
* @returns The filtered Set of items.
|
||||
*/
|
||||
const filterSelectableItems = (
|
||||
items: Set<Positionable>
|
||||
): Set<Positionable> => {
|
||||
const result = new Set<Positionable>()
|
||||
for (const item of items) {
|
||||
if (!isIgnoredItem(item)) {
|
||||
result.add(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filtered selected items from the canvas.
|
||||
* @returns The filtered Set of selected items.
|
||||
*/
|
||||
const getSelectableItems = (): Set<Positionable> => {
|
||||
const { selectedItems } = canvasStore.getCanvas()
|
||||
return filterSelectableItems(selectedItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any selectable items.
|
||||
* @returns True if there are selectable items, false otherwise.
|
||||
*/
|
||||
const hasSelectableItems = (): boolean => {
|
||||
return getSelectableItems().size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are multiple selectable items.
|
||||
* @returns True if there are multiple selectable items, false otherwise.
|
||||
*/
|
||||
const hasMultipleSelectableItems = (): boolean => {
|
||||
return getSelectableItems().size > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the selected nodes (LGraphNode instances) from the canvas.
|
||||
* This filters out other types of selected items like groups or reroutes.
|
||||
* If a selected node is a subgraph, this also includes all nodes within it.
|
||||
* @returns Array of selected LGraphNode instances and their descendants.
|
||||
*/
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
if (!selectedNodes) return []
|
||||
|
||||
// Convert selected_nodes object to array, preserving order
|
||||
const nodeArray: LGraphNode[] = []
|
||||
for (const i in selectedNodes) {
|
||||
nodeArray.push(selectedNodes[i])
|
||||
}
|
||||
|
||||
// Check if any selected nodes are subgraphs
|
||||
const hasSubgraphs = nodeArray.some(
|
||||
(node) => node.isSubgraphNode?.() && node.subgraph
|
||||
)
|
||||
|
||||
// If no subgraphs, just return the array directly to preserve order
|
||||
if (!hasSubgraphs) {
|
||||
return nodeArray
|
||||
}
|
||||
|
||||
// Use collectFromNodes to get all nodes including those in subgraphs
|
||||
return collectFromNodes(nodeArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the execution mode of all selected nodes with unified subgraph behavior.
|
||||
*
|
||||
* Top-level behavior (selected nodes): Standard toggle logic
|
||||
* - If the selected node is already in the specified mode → set to ALWAYS
|
||||
* - Otherwise → set to the specified mode
|
||||
*
|
||||
* Subgraph behavior (children of selected subgraph nodes): Unified state application
|
||||
* - All children inherit the same mode that their parent subgraph node was set to
|
||||
* - This creates predictable behavior: if you toggle a subgraph to "mute",
|
||||
* ALL nodes inside become muted, regardless of their previous individual states
|
||||
*
|
||||
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
|
||||
*/
|
||||
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
if (!selectedNodes) return
|
||||
|
||||
// Convert selected_nodes object to array
|
||||
const selectedNodeArray: LGraphNode[] = []
|
||||
for (const i in selectedNodes) {
|
||||
selectedNodeArray.push(selectedNodes[i])
|
||||
}
|
||||
|
||||
// Process each selected node independently to determine its target state and apply to children
|
||||
selectedNodeArray.forEach((selectedNode) => {
|
||||
// Apply standard toggle logic to the selected node itself
|
||||
const newModeForSelectedNode =
|
||||
selectedNode.mode === mode ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
|
||||
// If this selected node is a subgraph, apply the same mode uniformly to all its children
|
||||
// This ensures predictable behavior: all children get the same state as their parent
|
||||
if (selectedNode.isSubgraphNode?.() && selectedNode.subgraph) {
|
||||
traverseNodesDepthFirst([selectedNode], {
|
||||
visitor: (node) => {
|
||||
// Skip the parent node since we already handled it above
|
||||
if (node === selectedNode) return undefined
|
||||
|
||||
// Apply the parent's new mode to all children uniformly
|
||||
node.mode = newModeForSelectedNode
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isIgnoredItem,
|
||||
filterSelectableItems,
|
||||
getSelectableItems,
|
||||
hasSelectableItems,
|
||||
hasMultipleSelectableItems,
|
||||
getSelectedNodes,
|
||||
toggleSelectedNodesMode
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Point } from '@comfyorg/litegraph'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
@@ -29,7 +30,11 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
getAllNonIoNodesInSubgraph,
|
||||
getExecutionIdsForSelectedNodes
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
@@ -42,30 +47,10 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
const result: LGraphNode[] = []
|
||||
if (selectedNodes) {
|
||||
for (const i in selectedNodes) {
|
||||
const node = selectedNodes[i]
|
||||
result.push(node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const toggleSelectedNodesMode = (mode: LGraphEventMode) => {
|
||||
getSelectedNodes().forEach((node) => {
|
||||
if (node.mode === mode) {
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
} else {
|
||||
node.mode = mode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
@@ -363,10 +348,10 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.19.6',
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
const queueNodeIds = getSelectedNodes()
|
||||
.filter((node) => node.constructor.nodeData?.output_node)
|
||||
.map((node) => node.id)
|
||||
if (queueNodeIds.length === 0) {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
const selectedOutputNodes = filterOutputNodes(selectedNodes)
|
||||
|
||||
if (selectedOutputNodes.length === 0) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToQueue'),
|
||||
@@ -375,7 +360,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
return
|
||||
}
|
||||
await app.queuePrompt(0, batchCount, queueNodeIds)
|
||||
|
||||
// Get execution IDs for all selected output nodes and their descendants
|
||||
const executionIds =
|
||||
getExecutionIdsForSelectedNodes(selectedOutputNodes)
|
||||
await app.queuePrompt(0, batchCount, executionIds)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
interface GraphCallbacks {
|
||||
@@ -21,7 +20,6 @@ export function useMinimap() {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -110,14 +108,6 @@ export function useMinimap() {
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
const graph = ref(app.canvas?.graph)
|
||||
|
||||
// Update graph ref when subgraph context changes
|
||||
watch(
|
||||
() => workflowStore.activeSubgraph,
|
||||
() => {
|
||||
graph.value = app.canvas?.graph
|
||||
}
|
||||
)
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
@@ -362,6 +352,8 @@ export function useMinimap() {
|
||||
needsBoundsUpdate.value = false
|
||||
updateFlags.value.bounds = false
|
||||
needsFullRedraw.value = true
|
||||
// When bounds change, we need to update the viewport position
|
||||
updateFlags.value.viewport = true
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -371,6 +363,11 @@ export function useMinimap() {
|
||||
) {
|
||||
renderMinimap()
|
||||
}
|
||||
|
||||
// Update viewport if needed (e.g., after bounds change)
|
||||
if (updateFlags.value.viewport) {
|
||||
updateViewport()
|
||||
}
|
||||
}
|
||||
|
||||
const checkForChanges = useThrottleFn(() => {
|
||||
@@ -645,7 +642,7 @@ export function useMinimap() {
|
||||
await init()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
@@ -663,6 +660,8 @@ export function useMinimap() {
|
||||
|
||||
await nextTick()
|
||||
|
||||
await nextTick()
|
||||
|
||||
updateMinimap()
|
||||
updateViewport()
|
||||
resumeChangeDetection()
|
||||
|
||||
@@ -458,6 +458,24 @@ export type WorkflowJSON10 = z.infer<typeof zComfyWorkflow1>
|
||||
export type ComfyWorkflowJSON = z.infer<
|
||||
typeof zComfyWorkflow | typeof zComfyWorkflow1
|
||||
>
|
||||
export type SubgraphDefinition = z.infer<typeof zSubgraphDefinition>
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a SubgraphDefinition.
|
||||
* This helps TypeScript understand the type when z.lazy() breaks inference.
|
||||
*/
|
||||
export function isSubgraphDefinition(obj: any): obj is SubgraphDefinition {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'id' in obj &&
|
||||
'name' in obj &&
|
||||
'nodes' in obj &&
|
||||
Array.isArray(obj.nodes) &&
|
||||
'inputNode' in obj &&
|
||||
'outputNode' in obj
|
||||
)
|
||||
}
|
||||
|
||||
const zWorkflowVersion = z.object({
|
||||
version: z.number()
|
||||
|
||||
@@ -35,11 +35,13 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
interface QueuePromptRequestBody {
|
||||
client_id: string
|
||||
prompt: ComfyApiWorkflow
|
||||
partial_execution_targets?: NodeExecutionId[]
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: ComfyWorkflowJSON
|
||||
@@ -80,6 +82,18 @@ interface QueuePromptRequestBody {
|
||||
number?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for queuePrompt method
|
||||
*/
|
||||
interface QueuePromptOptions {
|
||||
/**
|
||||
* Optional list of node execution IDs to execute (partial execution).
|
||||
* Each ID represents a node's position in nested subgraphs.
|
||||
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
|
||||
*/
|
||||
partialExecutionTargets?: NodeExecutionId[]
|
||||
}
|
||||
|
||||
/** Dictionary of Frontend-generated API calls */
|
||||
interface FrontendApiCalls {
|
||||
graphChanged: ComfyWorkflowJSON
|
||||
@@ -610,18 +624,23 @@ export class ComfyApi extends EventTarget {
|
||||
/**
|
||||
* Queues a prompt to be executed
|
||||
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
|
||||
* @param {object} prompt The prompt data to queue
|
||||
* @param {object} data The prompt data to queue
|
||||
* @param {QueuePromptOptions} options Optional execution options
|
||||
* @throws {PromptExecutionError} If the prompt fails to execute
|
||||
*/
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON }
|
||||
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON },
|
||||
options?: QueuePromptOptions
|
||||
): Promise<PromptResponse> {
|
||||
const { output: prompt, workflow } = data
|
||||
|
||||
const body: QueuePromptRequestBody = {
|
||||
client_id: this.clientId ?? '', // TODO: Unify clientId access
|
||||
prompt,
|
||||
...(options?.partialExecutionTargets && {
|
||||
partial_execution_targets: options.partialExecutionTargets
|
||||
}),
|
||||
extra_data: {
|
||||
auth_token_comfy_org: this.authToken,
|
||||
api_key_comfy_org: this.apiKey,
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
ComfyApiWorkflow,
|
||||
type ComfyWorkflowJSON,
|
||||
type ModelFile,
|
||||
type NodeId
|
||||
type NodeId,
|
||||
isSubgraphDefinition
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import {
|
||||
type ComfyNodeDef as ComfyNodeDefV1,
|
||||
@@ -59,6 +60,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import { ExtensionManager } from '@/types/extensionTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import {
|
||||
@@ -127,7 +129,7 @@ export class ComfyApp {
|
||||
#queueItems: {
|
||||
number: number
|
||||
batchCount: number
|
||||
queueNodeIds?: NodeId[]
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
}[] = []
|
||||
/**
|
||||
* If the queue is currently being processed
|
||||
@@ -1091,23 +1093,51 @@ export class ComfyApp {
|
||||
|
||||
const embeddedModels: ModelFile[] = []
|
||||
|
||||
for (let n of graphData.nodes) {
|
||||
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
|
||||
if (n.type == 'T2IAdapterLoader') n.type = 'ControlNetLoader'
|
||||
if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage' //typo fix
|
||||
if (n.type == 'SDV_img2vid_Conditioning')
|
||||
n.type = 'SVD_img2vid_Conditioning' //typo fix
|
||||
const collectMissingNodesAndModels = (
|
||||
nodes: ComfyWorkflowJSON['nodes'],
|
||||
path: string = ''
|
||||
) => {
|
||||
for (let n of nodes) {
|
||||
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
|
||||
if (n.type == 'T2IAdapterLoader') n.type = 'ControlNetLoader'
|
||||
if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage' //typo fix
|
||||
if (n.type == 'SDV_img2vid_Conditioning')
|
||||
n.type = 'SVD_img2vid_Conditioning' //typo fix
|
||||
|
||||
// Find missing node types
|
||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||
missingNodeTypes.push(n.type)
|
||||
n.type = sanitizeNodeName(n.type)
|
||||
// Find missing node types
|
||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||
// Include context about subgraph location if applicable
|
||||
if (path) {
|
||||
missingNodeTypes.push({
|
||||
type: n.type,
|
||||
hint: `in subgraph '${path}'`
|
||||
})
|
||||
} else {
|
||||
missingNodeTypes.push(n.type)
|
||||
}
|
||||
n.type = sanitizeNodeName(n.type)
|
||||
}
|
||||
|
||||
// Collect models metadata from node
|
||||
const selectedModels = getSelectedModelsMetadata(n)
|
||||
if (selectedModels?.length) {
|
||||
embeddedModels.push(...selectedModels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect models metadata from node
|
||||
const selectedModels = getSelectedModelsMetadata(n)
|
||||
if (selectedModels?.length) {
|
||||
embeddedModels.push(...selectedModels)
|
||||
// Process nodes at the top level
|
||||
collectMissingNodesAndModels(graphData.nodes)
|
||||
|
||||
// Process nodes in subgraphs
|
||||
if (graphData.definitions?.subgraphs) {
|
||||
for (const subgraph of graphData.definitions.subgraphs) {
|
||||
if (isSubgraphDefinition(subgraph)) {
|
||||
collectMissingNodesAndModels(
|
||||
subgraph.nodes,
|
||||
subgraph.name || subgraph.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1239,20 +1269,16 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
|
||||
async graphToPrompt(
|
||||
graph = this.graph,
|
||||
options: { queueNodeIds?: NodeId[] } = {}
|
||||
) {
|
||||
async graphToPrompt(graph = this.graph) {
|
||||
return graphToPrompt(graph, {
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave'),
|
||||
queueNodeIds: options.queueNodeIds
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
})
|
||||
}
|
||||
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
batchCount: number = 1,
|
||||
queueNodeIds?: NodeId[]
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
): Promise<boolean> {
|
||||
this.#queueItems.push({ number, batchCount, queueNodeIds })
|
||||
|
||||
@@ -1281,11 +1307,13 @@ export class ComfyApp {
|
||||
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
|
||||
}
|
||||
|
||||
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
|
||||
const p = await this.graphToPrompt(this.graph)
|
||||
try {
|
||||
api.authToken = comfyOrgAuthToken
|
||||
api.apiKey = comfyOrgApiKey ?? undefined
|
||||
const res = await api.queuePrompt(number, p)
|
||||
const res = await api.queuePrompt(number, p, {
|
||||
partialExecutionTargets: queueNodeIds
|
||||
})
|
||||
delete api.authToken
|
||||
delete api.apiKey
|
||||
executionStore.lastNodeErrors = res.node_errors ?? null
|
||||
|
||||
@@ -73,7 +73,8 @@ export class ChangeTracker {
|
||||
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
|
||||
}
|
||||
const navigation = useSubgraphNavigationStore().exportState()
|
||||
this.subgraphState = navigation.length ? { navigation } : undefined
|
||||
// Always store the navigation state, even if empty (root level)
|
||||
this.subgraphState = { navigation }
|
||||
}
|
||||
|
||||
restore() {
|
||||
@@ -90,8 +91,14 @@ export class ChangeTracker {
|
||||
|
||||
const activeId = navigation.at(-1)
|
||||
if (activeId) {
|
||||
// Navigate to the saved subgraph
|
||||
const subgraph = app.graph.subgraphs.get(activeId)
|
||||
if (subgraph) app.canvas.setGraph(subgraph)
|
||||
if (subgraph) {
|
||||
app.canvas.setGraph(subgraph)
|
||||
}
|
||||
} else {
|
||||
// Empty navigation array means root level
|
||||
app.canvas.setGraph(app.graph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,21 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
|
||||
const getIdToken = async (): Promise<string | null> => {
|
||||
if (currentUser.value) {
|
||||
return currentUser.value.getIdToken()
|
||||
try {
|
||||
return await currentUser.value.getIdToken()
|
||||
} catch (error: any) {
|
||||
// Handle network-related Firebase Auth errors gracefully
|
||||
if (error?.code === 'auth/network-request-failed' ||
|
||||
error?.message?.includes('network') ||
|
||||
error?.message?.includes('Network error') ||
|
||||
error?.message?.includes('A network error has occurred')) {
|
||||
// Return null for network errors to allow offline functionality
|
||||
console.warn('Firebase Auth network error, working offline:', error.message)
|
||||
return null
|
||||
}
|
||||
// Re-throw other errors as they might be important
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import type { DragAndScaleState } from '@comfyorg/litegraph/dist/DragAndScale'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, shallowRef, watch } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { isNonNullish } from '@/utils/typeGuardUtil'
|
||||
|
||||
import { useCanvasStore } from './graphStore'
|
||||
@@ -25,19 +26,32 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
const activeSubgraph = shallowRef<Subgraph>()
|
||||
|
||||
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
|
||||
const idStack = shallowReactive<string[]>([])
|
||||
const idStack = ref<string[]>([])
|
||||
|
||||
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
|
||||
const viewportCache = new QuickLRU<string, DragAndScaleState>({
|
||||
maxSize: 32
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the ID of the root graph for the currently active workflow.
|
||||
* @returns The ID of the root graph for the currently active workflow.
|
||||
*/
|
||||
const getCurrentRootGraphId = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
if (!canvas) return 'root'
|
||||
|
||||
return canvas.graph?.rootGraph?.id ?? 'root'
|
||||
}
|
||||
|
||||
/**
|
||||
* A stack representing subgraph navigation history from the root graph to
|
||||
* the current opened subgraph.
|
||||
*/
|
||||
const navigationStack = computed(() =>
|
||||
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
|
||||
idStack.value
|
||||
.map((id) => app.graph.subgraphs.get(id))
|
||||
.filter(isNonNullish)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -46,8 +60,8 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
* @see exportState
|
||||
*/
|
||||
const restoreState = (subgraphIds: string[]) => {
|
||||
idStack.length = 0
|
||||
for (const id of subgraphIds) idStack.push(id)
|
||||
idStack.value.length = 0
|
||||
for (const id of subgraphIds) idStack.value.push(id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +69,7 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
* @returns The list of subgraph IDs, ending with the currently active subgraph.
|
||||
* @see restoreState
|
||||
*/
|
||||
const exportState = () => [...idStack]
|
||||
const exportState = () => [...idStack.value]
|
||||
|
||||
/**
|
||||
* Get the current viewport state.
|
||||
@@ -99,47 +113,49 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
// Reset on workflow change
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
() => {
|
||||
idStack.length = 0
|
||||
/**
|
||||
* Update the navigation stack when the active subgraph changes.
|
||||
* @param subgraph The new active subgraph.
|
||||
* @param prevSubgraph The previous active subgraph.
|
||||
*/
|
||||
const onNavigated = (
|
||||
subgraph: Subgraph | undefined,
|
||||
prevSubgraph: Subgraph | undefined
|
||||
) => {
|
||||
// Save viewport state for the graph we're leaving
|
||||
if (prevSubgraph) {
|
||||
// Leaving a subgraph
|
||||
saveViewport(prevSubgraph.id)
|
||||
} else if (!prevSubgraph && subgraph) {
|
||||
// Leaving root graph to enter a subgraph
|
||||
saveViewport(getCurrentRootGraphId())
|
||||
}
|
||||
)
|
||||
|
||||
// Update navigation stack when opened subgraph changes
|
||||
const isInRootGraph = !subgraph
|
||||
if (isInRootGraph) {
|
||||
idStack.value.length = 0
|
||||
restoreViewport(getCurrentRootGraphId())
|
||||
return
|
||||
}
|
||||
|
||||
const path = findSubgraphPathById(subgraph.rootGraph, subgraph.id)
|
||||
const isInReachableSubgraph = !!path
|
||||
if (isInReachableSubgraph) {
|
||||
idStack.value = [...path]
|
||||
} else {
|
||||
// Treat as if opening a new subgraph
|
||||
idStack.value = [subgraph.id]
|
||||
}
|
||||
|
||||
// Always try to restore viewport for the target subgraph
|
||||
restoreViewport(subgraph.id)
|
||||
}
|
||||
|
||||
// Update navigation stack when opened subgraph changes (also triggers when switching workflows)
|
||||
watch(
|
||||
() => workflowStore.activeSubgraph,
|
||||
(subgraph, prevSubgraph) => {
|
||||
// Save viewport state for the graph we're leaving
|
||||
if (prevSubgraph) {
|
||||
// Leaving a subgraph
|
||||
saveViewport(prevSubgraph.id)
|
||||
} else if (!prevSubgraph && subgraph) {
|
||||
// Leaving root graph to enter a subgraph
|
||||
saveViewport('root')
|
||||
}
|
||||
|
||||
// Navigated back to the root graph
|
||||
if (!subgraph) {
|
||||
idStack.length = 0
|
||||
restoreViewport('root')
|
||||
return
|
||||
}
|
||||
|
||||
const index = idStack.lastIndexOf(subgraph.id)
|
||||
const lastIndex = idStack.length - 1
|
||||
|
||||
if (index === -1) {
|
||||
// Opened a new subgraph
|
||||
idStack.push(subgraph.id)
|
||||
} else if (index !== lastIndex) {
|
||||
// Navigated to a different subgraph
|
||||
idStack.splice(index + 1, lastIndex - index)
|
||||
}
|
||||
|
||||
// Always try to restore viewport for the target subgraph
|
||||
restoreViewport(subgraph.id)
|
||||
(newValue, oldValue) => {
|
||||
onNavigated(newValue, oldValue)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type {
|
||||
ExecutableLGraphNode,
|
||||
ExecutionId,
|
||||
LGraph,
|
||||
NodeId
|
||||
LGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import {
|
||||
ExecutableNodeDTO,
|
||||
@@ -18,31 +17,6 @@ import type {
|
||||
import { ExecutableGroupNodeDTO, isGroupNode } from './executableGroupNodeDto'
|
||||
import { compressWidgetInputSlots } from './litegraphUtil'
|
||||
|
||||
/**
|
||||
* Recursively target node's parent nodes to the new output.
|
||||
* @param nodeId The node id to add.
|
||||
* @param oldOutput The old output.
|
||||
* @param newOutput The new output.
|
||||
* @returns The new output.
|
||||
*/
|
||||
function recursiveAddNodes(
|
||||
nodeId: NodeId,
|
||||
oldOutput: ComfyApiWorkflow,
|
||||
newOutput: ComfyApiWorkflow
|
||||
) {
|
||||
const currentId = String(nodeId)
|
||||
const currentNode = oldOutput[currentId]!
|
||||
if (newOutput[currentId] == null) {
|
||||
newOutput[currentId] = currentNode
|
||||
for (const inputValue of Object.values(currentNode.inputs || [])) {
|
||||
if (Array.isArray(inputValue)) {
|
||||
recursiveAddNodes(inputValue[0], oldOutput, newOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the current graph workflow for sending to the API.
|
||||
* @note Node widgets are updated before serialization to prepare queueing.
|
||||
@@ -50,14 +24,13 @@ function recursiveAddNodes(
|
||||
* @param graph The graph to convert.
|
||||
* @param options The options for the conversion.
|
||||
* - `sortNodes`: Whether to sort the nodes by execution order.
|
||||
* - `queueNodeIds`: The output nodes to execute. Execute all output nodes if not provided.
|
||||
* @returns The workflow and node links
|
||||
*/
|
||||
export const graphToPrompt = async (
|
||||
graph: LGraph,
|
||||
options: { sortNodes?: boolean; queueNodeIds?: NodeId[] } = {}
|
||||
options: { sortNodes?: boolean } = {}
|
||||
): Promise<{ workflow: ComfyWorkflowJSON; output: ComfyApiWorkflow }> => {
|
||||
const { sortNodes = false, queueNodeIds } = options
|
||||
const { sortNodes = false } = options
|
||||
|
||||
for (const node of graph.computeExecutionOrder(false)) {
|
||||
const innerNodes = node.getInnerNodes
|
||||
@@ -104,7 +77,7 @@ export const graphToPrompt = async (
|
||||
nodeDtoMap.set(dto.id, dto)
|
||||
}
|
||||
|
||||
let output: ComfyApiWorkflow = {}
|
||||
const output: ComfyApiWorkflow = {}
|
||||
// Process nodes in order of execution
|
||||
for (const node of nodeDtoMap.values()) {
|
||||
// Don't serialize muted nodes
|
||||
@@ -180,14 +153,5 @@ export const graphToPrompt = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Partial execution
|
||||
if (queueNodeIds?.length) {
|
||||
const newOutput = {}
|
||||
for (const queueNodeId of queueNodeIds) {
|
||||
recursiveAddNodes(queueNodeId, output, newOutput)
|
||||
}
|
||||
output = newOutput
|
||||
}
|
||||
|
||||
return { workflow: workflow as ComfyWorkflowJSON, output }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
|
||||
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { isSubgraphIoNode } from './typeGuardUtil'
|
||||
@@ -205,7 +205,7 @@ export function findSubgraphByUuid(
|
||||
targetUuid: string
|
||||
): Subgraph | null {
|
||||
// Check all nodes in the current graph
|
||||
for (const node of graph._nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
if (node.subgraph.id === targetUuid) {
|
||||
return node.subgraph
|
||||
@@ -218,6 +218,42 @@ export function findSubgraphByUuid(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Iteratively finds the path of subgraph IDs to a target subgraph.
|
||||
* @param rootGraph The graph to start searching from.
|
||||
* @param targetId The ID of the subgraph to find.
|
||||
* @returns An array of subgraph IDs representing the path, or `null` if not found.
|
||||
*/
|
||||
export function findSubgraphPathById(
|
||||
rootGraph: LGraph,
|
||||
targetId: string
|
||||
): string[] | null {
|
||||
const stack: { graph: LGraph | Subgraph; path: string[] }[] = [
|
||||
{ graph: rootGraph, path: [] }
|
||||
]
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { graph, path } = stack.pop()!
|
||||
|
||||
// Check if graph exists and has _nodes property
|
||||
if (!graph || !graph._nodes || !Array.isArray(graph._nodes)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
const newPath = [...path, String(node.subgraph.id)]
|
||||
if (node.subgraph.id === targetId) {
|
||||
return newPath
|
||||
}
|
||||
stack.push({ graph: node.subgraph, path: newPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node by its execution ID from anywhere in the graph hierarchy.
|
||||
* Execution IDs use hierarchical format like "123:456:789" for nested nodes.
|
||||
@@ -351,3 +387,131 @@ export function mapSubgraphNodes<T>(
|
||||
export function getAllNonIoNodesInSubgraph(subgraph: Subgraph): LGraphNode[] {
|
||||
return subgraph.nodes.filter((node) => !isSubgraphIoNode(node))
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for traverseNodesDepthFirst function
|
||||
*/
|
||||
export interface TraverseNodesOptions<T> {
|
||||
/** Function called for each node during traversal */
|
||||
visitor?: (node: LGraphNode, context: T) => T
|
||||
/** Initial context value */
|
||||
initialContext?: T
|
||||
/** Whether to traverse into subgraph nodes (default: true) */
|
||||
expandSubgraphs?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs depth-first traversal of nodes and their subgraphs.
|
||||
* Generic visitor pattern that can be used for various node processing tasks.
|
||||
*
|
||||
* @param nodes - Starting nodes for traversal
|
||||
* @param options - Optional traversal configuration
|
||||
*/
|
||||
export function traverseNodesDepthFirst<T = void>(
|
||||
nodes: LGraphNode[],
|
||||
options?: TraverseNodesOptions<T>
|
||||
): void {
|
||||
const {
|
||||
visitor = () => undefined as T,
|
||||
initialContext = undefined as T,
|
||||
expandSubgraphs = true
|
||||
} = options || {}
|
||||
type StackItem = { node: LGraphNode; context: T }
|
||||
const stack: StackItem[] = []
|
||||
|
||||
// Initialize stack with starting nodes
|
||||
for (const node of nodes) {
|
||||
stack.push({ node, context: initialContext })
|
||||
}
|
||||
|
||||
// Process stack iteratively (DFS)
|
||||
while (stack.length > 0) {
|
||||
const { node, context } = stack.pop()!
|
||||
|
||||
// Visit node and get updated context for children
|
||||
const childContext = visitor(node, context)
|
||||
|
||||
// If it's a subgraph and we should expand, add children to stack
|
||||
if (expandSubgraphs && node.isSubgraphNode?.() && node.subgraph) {
|
||||
// Process children in reverse order to maintain left-to-right DFS processing
|
||||
// when popping from stack (LIFO). Iterate backwards to avoid array reversal.
|
||||
const children = node.subgraph.nodes
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({ node: children[i], context: childContext })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for collectFromNodes function
|
||||
*/
|
||||
export interface CollectFromNodesOptions<T, C> {
|
||||
/** Function that returns data to collect for each node */
|
||||
collector?: (node: LGraphNode, context: C) => T | null
|
||||
/** Function that builds context for child nodes */
|
||||
contextBuilder?: (node: LGraphNode, parentContext: C) => C
|
||||
/** Initial context value */
|
||||
initialContext?: C
|
||||
/** Whether to traverse into subgraph nodes (default: true) */
|
||||
expandSubgraphs?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects nodes with custom data during depth-first traversal.
|
||||
* Generic collector that can gather any type of data per node.
|
||||
*
|
||||
* @param nodes - Starting nodes for traversal
|
||||
* @param options - Optional collection configuration
|
||||
* @returns Array of collected data
|
||||
*/
|
||||
export function collectFromNodes<T = LGraphNode, C = void>(
|
||||
nodes: LGraphNode[],
|
||||
options?: CollectFromNodesOptions<T, C>
|
||||
): T[] {
|
||||
const {
|
||||
collector = (node: LGraphNode) => node as unknown as T,
|
||||
contextBuilder = () => undefined as C,
|
||||
initialContext = undefined as C,
|
||||
expandSubgraphs = true
|
||||
} = options || {}
|
||||
const results: T[] = []
|
||||
|
||||
traverseNodesDepthFirst(nodes, {
|
||||
visitor: (node, context) => {
|
||||
const data = collector(node, context)
|
||||
if (data !== null) {
|
||||
results.push(data)
|
||||
}
|
||||
return contextBuilder(node, context)
|
||||
},
|
||||
initialContext,
|
||||
expandSubgraphs
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects execution IDs for selected nodes and all their descendants.
|
||||
* Uses the generic DFS traversal with optimized string building.
|
||||
*
|
||||
* @param selectedNodes - The selected nodes to process
|
||||
* @returns Array of execution IDs for selected nodes and all nodes within selected subgraphs
|
||||
*/
|
||||
export function getExecutionIdsForSelectedNodes(
|
||||
selectedNodes: LGraphNode[]
|
||||
): NodeExecutionId[] {
|
||||
return collectFromNodes<NodeExecutionId, string>(selectedNodes, {
|
||||
collector: (node, parentExecutionId) => {
|
||||
const nodeId = String(node.id)
|
||||
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
|
||||
},
|
||||
contextBuilder: (node, parentExecutionId) => {
|
||||
const nodeId = String(node.id)
|
||||
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
|
||||
},
|
||||
initialContext: '',
|
||||
expandSubgraphs: true
|
||||
})
|
||||
}
|
||||
|
||||
21
src/utils/nodeFilterUtil.ts
Normal file
21
src/utils/nodeFilterUtil.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
/**
|
||||
* Checks if a node is an output node.
|
||||
* Output nodes are nodes that have the output_node flag set in their nodeData.
|
||||
*
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is an output node, false otherwise
|
||||
*/
|
||||
export const isOutputNode = (node: LGraphNode) =>
|
||||
node.constructor.nodeData?.output_node
|
||||
|
||||
/**
|
||||
* Filters nodes to find only output nodes.
|
||||
* Output nodes are nodes that have the output_node flag set in their nodeData.
|
||||
*
|
||||
* @param nodes - Array of nodes to filter
|
||||
* @returns Array of output nodes only
|
||||
*/
|
||||
export const filterOutputNodes = (nodes: LGraphNode[]): LGraphNode[] =>
|
||||
nodes.filter(isOutputNode)
|
||||
13
tests-ui/CLAUDE.md
Normal file
13
tests-ui/CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Unit Testing Guidelines
|
||||
|
||||
## Testing Approach
|
||||
|
||||
- Write tests for new features
|
||||
- Run single tests for performance
|
||||
- Follow existing test patterns
|
||||
|
||||
## Test Structure
|
||||
|
||||
- Check @tests-ui/README.md for guidelines
|
||||
- Use existing test utilities
|
||||
- Mock external dependencies
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
Positionable,
|
||||
Reroute
|
||||
} from '@comfyorg/litegraph'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
// Mock the app module
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
selected_nodes: null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the litegraph module
|
||||
vi.mock('@comfyorg/litegraph', () => ({
|
||||
Reroute: class Reroute {
|
||||
constructor() {}
|
||||
},
|
||||
LGraphEventMode: {
|
||||
ALWAYS: 0,
|
||||
NEVER: 2,
|
||||
BYPASS: 4
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Positionable objects
|
||||
// @ts-expect-error - Mock implementation for testing
|
||||
class MockNode implements Positionable {
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
|
||||
constructor(
|
||||
pos: [number, number] = [0, 0],
|
||||
size: [number, number] = [100, 100]
|
||||
) {
|
||||
this.pos = pos
|
||||
this.size = size
|
||||
}
|
||||
}
|
||||
|
||||
class MockReroute extends Reroute implements Positionable {
|
||||
// @ts-expect-error - Override for testing
|
||||
override pos: [number, number]
|
||||
size: [number, number]
|
||||
|
||||
constructor(
|
||||
pos: [number, number] = [0, 0],
|
||||
size: [number, number] = [20, 20]
|
||||
) {
|
||||
// @ts-expect-error - Mock constructor
|
||||
super()
|
||||
this.pos = pos
|
||||
this.size = size
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSelectedLiteGraphItems', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
let mockCanvas: any
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
|
||||
// Mock canvas with selectedItems Set
|
||||
mockCanvas = {
|
||||
selectedItems: new Set<Positionable>()
|
||||
}
|
||||
|
||||
// Mock getCanvas to return our mock canvas
|
||||
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
|
||||
})
|
||||
|
||||
describe('isIgnoredItem', () => {
|
||||
it('should return true for Reroute instances', () => {
|
||||
const { isIgnoredItem } = useSelectedLiteGraphItems()
|
||||
const reroute = new MockReroute()
|
||||
expect(isIgnoredItem(reroute)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-Reroute items', () => {
|
||||
const { isIgnoredItem } = useSelectedLiteGraphItems()
|
||||
const node = new MockNode()
|
||||
// @ts-expect-error - Test mock
|
||||
expect(isIgnoredItem(node)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterSelectableItems', () => {
|
||||
it('should filter out Reroute items', () => {
|
||||
const { filterSelectableItems } = useSelectedLiteGraphItems()
|
||||
const node1 = new MockNode([0, 0])
|
||||
const node2 = new MockNode([100, 100])
|
||||
const reroute = new MockReroute([50, 50])
|
||||
|
||||
// @ts-expect-error - Test mocks
|
||||
const items = new Set<Positionable>([node1, node2, reroute])
|
||||
const filtered = filterSelectableItems(items)
|
||||
|
||||
expect(filtered.size).toBe(2)
|
||||
// @ts-expect-error - Test mocks
|
||||
expect(filtered.has(node1)).toBe(true)
|
||||
// @ts-expect-error - Test mocks
|
||||
expect(filtered.has(node2)).toBe(true)
|
||||
expect(filtered.has(reroute)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return empty set when all items are ignored', () => {
|
||||
const { filterSelectableItems } = useSelectedLiteGraphItems()
|
||||
const reroute1 = new MockReroute([0, 0])
|
||||
const reroute2 = new MockReroute([50, 50])
|
||||
|
||||
const items = new Set<Positionable>([reroute1, reroute2])
|
||||
const filtered = filterSelectableItems(items)
|
||||
|
||||
expect(filtered.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle empty set', () => {
|
||||
const { filterSelectableItems } = useSelectedLiteGraphItems()
|
||||
const items = new Set<Positionable>()
|
||||
const filtered = filterSelectableItems(items)
|
||||
|
||||
expect(filtered.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('methods', () => {
|
||||
it('getSelectableItems should return only non-ignored items', () => {
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
const node1 = new MockNode()
|
||||
const node2 = new MockNode()
|
||||
const reroute = new MockReroute()
|
||||
|
||||
mockCanvas.selectedItems.add(node1)
|
||||
mockCanvas.selectedItems.add(node2)
|
||||
mockCanvas.selectedItems.add(reroute)
|
||||
|
||||
const selectableItems = getSelectableItems()
|
||||
expect(selectableItems.size).toBe(2)
|
||||
// @ts-expect-error - Test mock
|
||||
expect(selectableItems.has(node1)).toBe(true)
|
||||
// @ts-expect-error - Test mock
|
||||
expect(selectableItems.has(node2)).toBe(true)
|
||||
expect(selectableItems.has(reroute)).toBe(false)
|
||||
})
|
||||
|
||||
it('hasSelectableItems should be true when there are selectable items', () => {
|
||||
const { hasSelectableItems } = useSelectedLiteGraphItems()
|
||||
const node = new MockNode()
|
||||
|
||||
expect(hasSelectableItems()).toBe(false)
|
||||
|
||||
mockCanvas.selectedItems.add(node)
|
||||
expect(hasSelectableItems()).toBe(true)
|
||||
})
|
||||
|
||||
it('hasSelectableItems should be false when only ignored items are selected', () => {
|
||||
const { hasSelectableItems } = useSelectedLiteGraphItems()
|
||||
const reroute = new MockReroute()
|
||||
|
||||
mockCanvas.selectedItems.add(reroute)
|
||||
expect(hasSelectableItems()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasMultipleSelectableItems should be true when there are 2+ selectable items', () => {
|
||||
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
|
||||
const node1 = new MockNode()
|
||||
const node2 = new MockNode()
|
||||
|
||||
expect(hasMultipleSelectableItems()).toBe(false)
|
||||
|
||||
mockCanvas.selectedItems.add(node1)
|
||||
expect(hasMultipleSelectableItems()).toBe(false)
|
||||
|
||||
mockCanvas.selectedItems.add(node2)
|
||||
expect(hasMultipleSelectableItems()).toBe(true)
|
||||
})
|
||||
|
||||
it('hasMultipleSelectableItems should not count ignored items', () => {
|
||||
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
|
||||
const node = new MockNode()
|
||||
const reroute1 = new MockReroute()
|
||||
const reroute2 = new MockReroute()
|
||||
|
||||
mockCanvas.selectedItems.add(node)
|
||||
mockCanvas.selectedItems.add(reroute1)
|
||||
mockCanvas.selectedItems.add(reroute2)
|
||||
|
||||
// Even though there are 3 items total, only 1 is selectable
|
||||
expect(hasMultipleSelectableItems()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node-specific methods', () => {
|
||||
it('getSelectedNodes should return only LGraphNode instances', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
|
||||
// Mock app.canvas.selected_nodes
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(2)
|
||||
expect(selectedNodes[0]).toBe(node1)
|
||||
expect(selectedNodes[1]).toBe(node2)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should return empty array when no nodes selected', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
|
||||
// @ts-expect-error - Testing null case
|
||||
app.canvas.selected_nodes = null
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('toggleSelectedNodesMode should toggle node modes correctly', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
|
||||
// Toggle to NEVER mode
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
|
||||
// node1 should change from ALWAYS to NEVER
|
||||
// node2 should change from NEVER to ALWAYS (since it was already NEVER)
|
||||
expect(node1.mode).toBe(LGraphEventMode.NEVER)
|
||||
expect(node2.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
|
||||
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const node = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': node }
|
||||
|
||||
// Toggle to BYPASS mode (node is already BYPASS)
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
|
||||
// Should change to ALWAYS
|
||||
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should include nodes from subgraphs', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subgraphNode = {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.ALWAYS,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
nodes: [subNode1, subNode2]
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(4) // subgraphNode + 2 sub nodes + regularNode
|
||||
expect(selectedNodes).toContainEqual(subgraphNode)
|
||||
expect(selectedNodes).toContainEqual(regularNode)
|
||||
expect(selectedNodes).toContainEqual(subNode1)
|
||||
expect(selectedNodes).toContainEqual(subNode2)
|
||||
})
|
||||
|
||||
it('toggleSelectedNodesMode should apply unified state to subgraph children', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subgraphNode = {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.ALWAYS,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
nodes: [subNode1, subNode2]
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
|
||||
|
||||
// Toggle to NEVER mode
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
|
||||
// Selected nodes follow standard toggle logic:
|
||||
// subgraphNode: ALWAYS -> NEVER (since ALWAYS != NEVER)
|
||||
expect(subgraphNode.mode).toBe(LGraphEventMode.NEVER)
|
||||
// regularNode: BYPASS -> NEVER (since BYPASS != NEVER)
|
||||
expect(regularNode.mode).toBe(LGraphEventMode.NEVER)
|
||||
|
||||
// Subgraph children get unified state (same as their parent):
|
||||
// Both children should now be NEVER, regardless of their previous states
|
||||
expect(subNode1.mode).toBe(LGraphEventMode.NEVER) // was ALWAYS, now NEVER
|
||||
expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER
|
||||
})
|
||||
|
||||
it('toggleSelectedNodesMode should toggle to ALWAYS when subgraph is already in target mode', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const subgraphNode = {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.NEVER, // Already in NEVER mode
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
nodes: [subNode1, subNode2]
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode }
|
||||
|
||||
// Toggle to NEVER mode (but subgraphNode is already NEVER)
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
|
||||
// Selected subgraph should toggle to ALWAYS (since it was already NEVER)
|
||||
expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
|
||||
// All children should also get ALWAYS (unified with parent's new state)
|
||||
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic behavior', () => {
|
||||
it('methods should reflect changes when selectedItems change', () => {
|
||||
const {
|
||||
getSelectableItems,
|
||||
hasSelectableItems,
|
||||
hasMultipleSelectableItems
|
||||
} = useSelectedLiteGraphItems()
|
||||
const node1 = new MockNode()
|
||||
const node2 = new MockNode()
|
||||
|
||||
expect(hasSelectableItems()).toBe(false)
|
||||
expect(hasMultipleSelectableItems()).toBe(false)
|
||||
|
||||
// Add first node
|
||||
mockCanvas.selectedItems.add(node1)
|
||||
expect(hasSelectableItems()).toBe(true)
|
||||
expect(hasMultipleSelectableItems()).toBe(false)
|
||||
expect(getSelectableItems().size).toBe(1)
|
||||
|
||||
// Add second node
|
||||
mockCanvas.selectedItems.add(node2)
|
||||
expect(hasSelectableItems()).toBe(true)
|
||||
expect(hasMultipleSelectableItems()).toBe(true)
|
||||
expect(getSelectableItems().size).toBe(2)
|
||||
|
||||
// Remove a node
|
||||
mockCanvas.selectedItems.delete(node1)
|
||||
expect(hasSelectableItems()).toBe(true)
|
||||
expect(hasMultipleSelectableItems()).toBe(false)
|
||||
expect(getSelectableItems().size).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,35 @@ import * as vuefire from 'vuefire'
|
||||
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
// Track Firebase network requests without MSW for now
|
||||
const firebaseNetworkRequests: string[] = []
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// Create a tracking wrapper for fetch
|
||||
const createTrackingFetch = (originalMock: typeof mockFetch) => {
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
// Track Firebase-related requests
|
||||
const firebaseDomains = [
|
||||
'googleapis.com',
|
||||
'firebaseapp.com',
|
||||
'firebaseio.com',
|
||||
'identitytoolkit.googleapis.com',
|
||||
'securetoken.googleapis.com'
|
||||
]
|
||||
|
||||
if (firebaseDomains.some(domain => url.includes(domain))) {
|
||||
firebaseNetworkRequests.push(url)
|
||||
}
|
||||
|
||||
return originalMock(input, init)
|
||||
})
|
||||
}
|
||||
|
||||
vi.stubGlobal('fetch', createTrackingFetch(mockFetch))
|
||||
|
||||
// Mock successful API responses
|
||||
const mockCreateCustomerResponse = {
|
||||
@@ -92,6 +118,9 @@ describe('useFirebaseAuthStore', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
|
||||
// Clear network request tracking
|
||||
firebaseNetworkRequests.length = 0
|
||||
|
||||
// Mock useFirebaseAuth to return our mock auth object
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
|
||||
@@ -108,17 +137,19 @@ describe('useFirebaseAuthStore', () => {
|
||||
)
|
||||
|
||||
// Mock fetch responses
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith('/customers')) {
|
||||
mockFetch.mockImplementation((url: string | URL | Request) => {
|
||||
const urlString = typeof url === 'string' ? url : url.toString()
|
||||
|
||||
if (urlString.endsWith('/customers')) {
|
||||
return Promise.resolve(mockCreateCustomerResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/balance')) {
|
||||
if (urlString.endsWith('/customers/balance')) {
|
||||
return Promise.resolve(mockFetchBalanceResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/credit')) {
|
||||
if (urlString.endsWith('/customers/credit')) {
|
||||
return Promise.resolve(mockAddCreditsResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/billing-portal')) {
|
||||
if (urlString.endsWith('/customers/billing')) {
|
||||
return Promise.resolve(mockAccessBillingPortalResponse)
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected API call'))
|
||||
@@ -414,4 +445,77 @@ describe('useFirebaseAuthStore', () => {
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('offline network request issues', () => {
|
||||
it('should not make network requests during store initialization when offline', () => {
|
||||
// Reset network request tracking
|
||||
firebaseNetworkRequests.length = 0
|
||||
|
||||
// Initialize Pinia and store - this should not trigger network requests
|
||||
setActivePinia(createPinia())
|
||||
const offlineStore = useFirebaseAuthStore()
|
||||
|
||||
// Verify no Firebase network requests were made during initialization
|
||||
expect(firebaseNetworkRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle getIdToken gracefully when network is unavailable', async () => {
|
||||
// Set up a user with an expired token that would normally trigger a refresh
|
||||
const expiredTokenUser = {
|
||||
...mockUser,
|
||||
getIdToken: vi.fn().mockRejectedValue(
|
||||
new Error('Firebase: Error (auth/network-request-failed)')
|
||||
)
|
||||
}
|
||||
|
||||
// Simulate user being set but network being unavailable
|
||||
authStateCallback(expiredTokenUser)
|
||||
|
||||
const token = await store.getIdToken()
|
||||
|
||||
// Should return null instead of throwing error
|
||||
expect(token).toBeNull()
|
||||
expect(expiredTokenUser.getIdToken).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw errors when getIdToken fails due to network issues', async () => {
|
||||
const networkFailureUser = {
|
||||
...mockUser,
|
||||
getIdToken: vi.fn().mockRejectedValue({
|
||||
code: 'auth/network-request-failed',
|
||||
message: 'A network error has occurred.'
|
||||
})
|
||||
}
|
||||
|
||||
authStateCallback(networkFailureUser)
|
||||
|
||||
// Should not throw, should return null
|
||||
await expect(store.getIdToken()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive getIdToken calls without accumulating requests', async () => {
|
||||
const failingUser = {
|
||||
...mockUser,
|
||||
getIdToken: vi.fn().mockRejectedValue({
|
||||
code: 'auth/network-request-failed',
|
||||
message: 'A network error has occurred.'
|
||||
})
|
||||
}
|
||||
|
||||
authStateCallback(failingUser)
|
||||
|
||||
// Make multiple calls
|
||||
const results = await Promise.all([
|
||||
store.getIdToken(),
|
||||
store.getIdToken(),
|
||||
store.getIdToken()
|
||||
])
|
||||
|
||||
// All should return null
|
||||
expect(results).toEqual([null, null, null])
|
||||
|
||||
// Should only have made the actual attempts, not accumulate
|
||||
expect(failingUser.getIdToken).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,20 +2,46 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
subgraph: null,
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0],
|
||||
state: {
|
||||
scale: 1,
|
||||
offset: [0, 0]
|
||||
}
|
||||
},
|
||||
canvas: {
|
||||
subgraph: null
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
graph: {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
},
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/graphStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => (app as any).canvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
findSubgraphPathById: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useSubgraphNavigationStore', () => {
|
||||
@@ -51,42 +77,77 @@ describe('useSubgraphNavigationStore', () => {
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
|
||||
})
|
||||
|
||||
it('should clear navigation stack when switching to a different workflow', async () => {
|
||||
it('should preserve navigation stack per workflow', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Mock first workflow
|
||||
const workflow1 = {
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json'
|
||||
} as ComfyWorkflow
|
||||
filename: 'workflow1.json',
|
||||
changeTracker: {
|
||||
restore: vi.fn(),
|
||||
store: vi.fn()
|
||||
}
|
||||
} as unknown as ComfyWorkflow
|
||||
|
||||
// Set the active workflow
|
||||
workflowStore.activeWorkflow = workflow1 as any
|
||||
|
||||
// Simulate being in a subgraph
|
||||
// Simulate the restore process that happens when loading a workflow
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
|
||||
|
||||
// Verify navigation was set
|
||||
expect(navigationStore.exportState()).toHaveLength(2)
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
|
||||
|
||||
// Switch to a different workflow
|
||||
// Switch to a different workflow with no subgraph state (root level)
|
||||
const workflow2 = {
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json'
|
||||
} as ComfyWorkflow
|
||||
filename: 'workflow2.json',
|
||||
changeTracker: {
|
||||
restore: vi.fn(),
|
||||
store: vi.fn()
|
||||
}
|
||||
} as unknown as ComfyWorkflow
|
||||
|
||||
workflowStore.activeWorkflow = workflow2 as any
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
await nextTick()
|
||||
// Simulate the restore process for workflow2
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
navigationStore.restoreState([])
|
||||
|
||||
// The navigation stack SHOULD be cleared because we switched workflows
|
||||
// The navigation stack should be empty for workflow2 (at root level)
|
||||
expect(navigationStore.exportState()).toHaveLength(0)
|
||||
|
||||
// Switch back to workflow1
|
||||
workflowStore.activeWorkflow = workflow1 as any
|
||||
|
||||
// Simulate the restore process for workflow1 again
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
|
||||
|
||||
// The navigation stack should be restored for workflow1
|
||||
expect(navigationStore.exportState()).toHaveLength(2)
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
|
||||
})
|
||||
|
||||
it('should handle null workflow gracefully', async () => {
|
||||
it('should clear navigation when activeSubgraph becomes undefined', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
// Create mock subgraph and graph structure
|
||||
const mockSubgraph = {
|
||||
id: 'subgraph-1',
|
||||
rootGraph: (app as any).graph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
}
|
||||
|
||||
// Add the subgraph to the graph's subgraphs map
|
||||
;(app as any).graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
|
||||
// First set an active workflow
|
||||
const mockWorkflow = {
|
||||
@@ -95,19 +156,29 @@ describe('useSubgraphNavigationStore', () => {
|
||||
} as ComfyWorkflow
|
||||
|
||||
workflowStore.activeWorkflow = mockWorkflow as any
|
||||
await nextTick()
|
||||
|
||||
// Add some items to the navigation stack
|
||||
navigationStore.restoreState(['subgraph-1'])
|
||||
expect(navigationStore.exportState()).toHaveLength(1)
|
||||
// Mock findSubgraphPathById to return the correct path
|
||||
vi.mocked(findSubgraphPathById).mockReturnValue(['subgraph-1'])
|
||||
|
||||
// Set workflow to null
|
||||
workflowStore.activeWorkflow = null
|
||||
// Set canvas.subgraph and trigger update to set activeSubgraph
|
||||
;(app as any).canvas.subgraph = mockSubgraph
|
||||
workflowStore.updateActiveGraph()
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
await nextTick()
|
||||
|
||||
// Stack should be cleared when workflow becomes null
|
||||
// Verify navigation was set by the watcher
|
||||
expect(navigationStore.exportState()).toHaveLength(1)
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1'])
|
||||
|
||||
// Clear canvas.subgraph and trigger update (simulating navigating back to root)
|
||||
;(app as any).canvas.subgraph = null
|
||||
workflowStore.updateActiveGraph()
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
await nextTick()
|
||||
|
||||
// Stack should be cleared when activeSubgraph becomes undefined
|
||||
expect(navigationStore.exportState()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,8 @@ vi.mock('@/scripts/app', () => {
|
||||
return {
|
||||
app: {
|
||||
graph: {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
},
|
||||
@@ -173,16 +175,29 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Create mock subgraph with both _nodes and nodes properties
|
||||
const mockRootGraph = {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
const subgraph1 = {
|
||||
id: 'sub1',
|
||||
rootGraph: mockRootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
}
|
||||
|
||||
// Start at root with custom viewport
|
||||
mockCanvas.ds.state.scale = 2
|
||||
mockCanvas.ds.state.offset = [100, 100]
|
||||
|
||||
// Navigate to subgraph
|
||||
const subgraph1 = { id: 'sub1' }
|
||||
workflowStore.activeSubgraph = subgraph1 as any
|
||||
await nextTick()
|
||||
|
||||
// Root viewport should have been saved
|
||||
// Root viewport should have been saved automatically
|
||||
const rootViewport = navigationStore.viewportCache.get('root')
|
||||
expect(rootViewport).toBeDefined()
|
||||
expect(rootViewport?.scale).toBe(2)
|
||||
@@ -196,13 +211,13 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
workflowStore.activeSubgraph = undefined
|
||||
await nextTick()
|
||||
|
||||
// Subgraph viewport should have been saved
|
||||
// Subgraph viewport should have been saved automatically
|
||||
const sub1Viewport = navigationStore.viewportCache.get('sub1')
|
||||
expect(sub1Viewport).toBeDefined()
|
||||
expect(sub1Viewport?.scale).toBe(0.5)
|
||||
expect(sub1Viewport?.offset).toEqual([-50, -50])
|
||||
|
||||
// Root viewport should be restored
|
||||
// Root viewport should be restored automatically
|
||||
expect(mockCanvas.ds.scale).toBe(2)
|
||||
expect(mockCanvas.ds.offset).toEqual([100, 100])
|
||||
})
|
||||
|
||||
@@ -597,7 +597,9 @@ describe('useWorkflowStore', () => {
|
||||
// Setup mock graph structure with subgraphs
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
_nodes: []
|
||||
rootGraph: null as any,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
@@ -608,6 +610,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
const mockRootGraph = {
|
||||
_nodes: [mockNode],
|
||||
nodes: [mockNode],
|
||||
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
|
||||
getNodeById: (id: string | number) => {
|
||||
if (String(id) === '123') return mockNode
|
||||
@@ -615,6 +618,8 @@ describe('useWorkflowStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
mockSubgraph.rootGraph = mockRootGraph as any
|
||||
|
||||
vi.mocked(comfyApp).graph = mockRootGraph as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
store.activeSubgraph = mockSubgraph as any
|
||||
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
collectAllNodes,
|
||||
collectFromNodes,
|
||||
findNodeInHierarchy,
|
||||
findSubgraphByUuid,
|
||||
forEachNode,
|
||||
forEachSubgraphNode,
|
||||
getAllNonIoNodesInSubgraph,
|
||||
getExecutionIdsForSelectedNodes,
|
||||
getLocalNodeIdFromExecutionId,
|
||||
getNodeByExecutionId,
|
||||
getNodeByLocatorId,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
mapAllNodes,
|
||||
mapSubgraphNodes,
|
||||
parseExecutionId,
|
||||
traverseNodesDepthFirst,
|
||||
traverseSubgraphPath,
|
||||
triggerCallbackOnAllNodes,
|
||||
visitGraphNodes
|
||||
@@ -807,5 +810,370 @@ describe('graphTraversalUtil', () => {
|
||||
expect(nonIoNodes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traverseNodesDepthFirst', () => {
|
||||
it('should traverse nodes in depth-first order', () => {
|
||||
const visited: string[] = []
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2'),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
traverseNodesDepthFirst(nodes, {
|
||||
visitor: (node, context) => {
|
||||
visited.push(`${node.id}:${context}`)
|
||||
return `${context}-${node.id}`
|
||||
},
|
||||
initialContext: 'root'
|
||||
})
|
||||
|
||||
expect(visited).toEqual(['3:root', '2:root', '1:root']) // DFS processes in LIFO order
|
||||
})
|
||||
|
||||
it('should traverse into subgraphs when expandSubgraphs is true', () => {
|
||||
const visited: string[] = []
|
||||
const subNode = createMockNode('sub1')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
traverseNodesDepthFirst(nodes, {
|
||||
visitor: (node, depth: number) => {
|
||||
visited.push(`${node.id}:${depth}`)
|
||||
return depth + 1
|
||||
},
|
||||
initialContext: 0
|
||||
})
|
||||
|
||||
expect(visited).toEqual(['2:0', 'sub1:1', '1:0']) // DFS: last node first, then its children
|
||||
})
|
||||
|
||||
it('should skip subgraphs when expandSubgraphs is false', () => {
|
||||
const visited: string[] = []
|
||||
const subNode = createMockNode('sub1')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
traverseNodesDepthFirst(nodes, {
|
||||
visitor: (node, context) => {
|
||||
visited.push(String(node.id))
|
||||
return context
|
||||
},
|
||||
initialContext: null,
|
||||
expandSubgraphs: false
|
||||
})
|
||||
|
||||
expect(visited).toEqual(['2', '1']) // DFS processes in LIFO order
|
||||
expect(visited).not.toContain('sub1')
|
||||
})
|
||||
|
||||
it('should handle deeply nested subgraphs', () => {
|
||||
const visited: string[] = []
|
||||
|
||||
const deepNode = createMockNode('300')
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
|
||||
|
||||
const midNode = createMockNode('200', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
|
||||
|
||||
const topNode = createMockNode('100', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
traverseNodesDepthFirst([topNode], {
|
||||
visitor: (node, path: string) => {
|
||||
visited.push(`${node.id}:${path}`)
|
||||
return path ? `${path}/${node.id}` : String(node.id)
|
||||
},
|
||||
initialContext: ''
|
||||
})
|
||||
|
||||
expect(visited).toEqual(['100:', '200:100', '300:100/200'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFromNodes', () => {
|
||||
it('should collect data from all nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2'),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
const results = collectFromNodes(nodes, {
|
||||
collector: (node) => `node-${node.id}`,
|
||||
contextBuilder: (_node, context) => context,
|
||||
initialContext: null
|
||||
})
|
||||
|
||||
expect(results).toEqual(['node-3', 'node-2', 'node-1']) // DFS processes in LIFO order
|
||||
})
|
||||
|
||||
it('should filter out null results', () => {
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2'),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
const results = collectFromNodes(nodes, {
|
||||
collector: (node) => (Number(node.id) > 1 ? `node-${node.id}` : null),
|
||||
contextBuilder: (_node, context) => context,
|
||||
initialContext: null
|
||||
})
|
||||
|
||||
expect(results).toEqual(['node-3', 'node-2']) // DFS processes in LIFO order, node-1 filtered out
|
||||
})
|
||||
|
||||
it('should collect from subgraphs with context', () => {
|
||||
const subNodes = [createMockNode('10'), createMockNode('11')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const results = collectFromNodes(nodes, {
|
||||
collector: (node, prefix: string) => `${prefix}${node.id}`,
|
||||
contextBuilder: (node, prefix: string) => `${prefix}${node.id}-`,
|
||||
initialContext: 'node-',
|
||||
expandSubgraphs: true
|
||||
})
|
||||
|
||||
expect(results).toEqual([
|
||||
'node-2',
|
||||
'node-2-10', // Actually processes in original order within subgraph
|
||||
'node-2-11',
|
||||
'node-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('should not expand subgraphs when expandSubgraphs is false', () => {
|
||||
const subNodes = [createMockNode('10'), createMockNode('11')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const results = collectFromNodes(nodes, {
|
||||
collector: (node) => String(node.id),
|
||||
contextBuilder: (_node, context) => context,
|
||||
initialContext: null,
|
||||
expandSubgraphs: false
|
||||
})
|
||||
|
||||
expect(results).toEqual(['2', '1']) // DFS processes in LIFO order
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExecutionIdsForSelectedNodes', () => {
|
||||
it('should return simple IDs for top-level nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode('123'),
|
||||
createMockNode('456'),
|
||||
createMockNode('789')
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual(['789', '456', '123']) // DFS processes in LIFO order
|
||||
})
|
||||
|
||||
it('should expand subgraph nodes to include all children', () => {
|
||||
const subNodes = [createMockNode('10'), createMockNode('11')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual(['2', '2:10', '2:11', '1']) // DFS: node 2 first, then its children
|
||||
})
|
||||
|
||||
it('should handle deeply nested subgraphs correctly', () => {
|
||||
const deepNodes = [createMockNode('30'), createMockNode('31')]
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', deepNodes)
|
||||
|
||||
const midNode = createMockNode('20', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
|
||||
|
||||
const topNode = createMockNode('10', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes([topNode])
|
||||
|
||||
expect(executionIds).toEqual(['10', '10:20', '10:20:30', '10:20:31'])
|
||||
})
|
||||
|
||||
it('should handle mixed selection of regular and subgraph nodes', () => {
|
||||
const subNodes = [createMockNode('100'), createMockNode('101')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph }),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual([
|
||||
'3',
|
||||
'2',
|
||||
'2:100', // Subgraph children in original order
|
||||
'2:101',
|
||||
'1'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty selection', () => {
|
||||
const executionIds = getExecutionIdsForSelectedNodes([])
|
||||
expect(executionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle subgraph with no children', () => {
|
||||
const emptySubgraph = createMockSubgraph('empty-uuid', [])
|
||||
const node = createMockNode('1', {
|
||||
isSubgraph: true,
|
||||
subgraph: emptySubgraph
|
||||
})
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes([node])
|
||||
|
||||
expect(executionIds).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('should handle nodes with very long execution paths', () => {
|
||||
// Create a chain of 10 nested subgraphs
|
||||
let currentSubgraph = createMockSubgraph('deep-10', [
|
||||
createMockNode('10')
|
||||
])
|
||||
|
||||
for (let i = 9; i >= 1; i--) {
|
||||
const node = createMockNode(`${i}0`, {
|
||||
isSubgraph: true,
|
||||
subgraph: currentSubgraph
|
||||
})
|
||||
currentSubgraph = createMockSubgraph(`deep-${i}`, [node])
|
||||
}
|
||||
|
||||
const topNode = createMockNode('1', {
|
||||
isSubgraph: true,
|
||||
subgraph: currentSubgraph
|
||||
})
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes([topNode])
|
||||
|
||||
expect(executionIds).toHaveLength(11)
|
||||
expect(executionIds[0]).toBe('1')
|
||||
expect(executionIds[10]).toBe('1:10:20:30:40:50:60:70:80:90:10')
|
||||
})
|
||||
|
||||
it('should handle duplicate node IDs in different subgraphs', () => {
|
||||
// Create two subgraphs with nodes that have the same IDs
|
||||
const subgraph1 = createMockSubgraph('sub1-uuid', [
|
||||
createMockNode('100'),
|
||||
createMockNode('101')
|
||||
])
|
||||
|
||||
const subgraph2 = createMockSubgraph('sub2-uuid', [
|
||||
createMockNode('100'), // Same ID as in subgraph1
|
||||
createMockNode('101') // Same ID as in subgraph1
|
||||
])
|
||||
|
||||
const nodes = [
|
||||
createMockNode('1', { isSubgraph: true, subgraph: subgraph1 }),
|
||||
createMockNode('2', { isSubgraph: true, subgraph: subgraph2 })
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual([
|
||||
'2',
|
||||
'2:100',
|
||||
'2:101',
|
||||
'1',
|
||||
'1:100',
|
||||
'1:101'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle subgraphs with many children efficiently', () => {
|
||||
// Create a subgraph with 100 nodes
|
||||
const manyNodes = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
manyNodes.push(createMockNode(`child-${i}`))
|
||||
}
|
||||
const bigSubgraph = createMockSubgraph('big-uuid', manyNodes)
|
||||
|
||||
const node = createMockNode('parent', {
|
||||
isSubgraph: true,
|
||||
subgraph: bigSubgraph
|
||||
})
|
||||
|
||||
const start = performance.now()
|
||||
const executionIds = getExecutionIdsForSelectedNodes([node])
|
||||
const duration = performance.now() - start
|
||||
|
||||
expect(executionIds).toHaveLength(101)
|
||||
expect(executionIds[0]).toBe('parent')
|
||||
expect(executionIds[100]).toBe('parent:child-99') // Due to backward iteration optimization
|
||||
|
||||
// Should complete quickly even with many nodes
|
||||
expect(duration).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('should handle selection of nodes at different depths', () => {
|
||||
// Create a complex nested structure
|
||||
const deepNode = createMockNode('300')
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
|
||||
|
||||
const midNode1 = createMockNode('201')
|
||||
const midNode2 = createMockNode('202', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
|
||||
|
||||
const topNode = createMockNode('100', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
// Select nodes at different nesting levels
|
||||
const selectedNodes = [
|
||||
createMockNode('1'), // Root level
|
||||
topNode, // Contains subgraph
|
||||
createMockNode('2') // Root level
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(selectedNodes)
|
||||
|
||||
expect(executionIds).toContain('1')
|
||||
expect(executionIds).toContain('2')
|
||||
expect(executionIds).toContain('100')
|
||||
expect(executionIds).toContain('100:201')
|
||||
expect(executionIds).toContain('100:202')
|
||||
expect(executionIds).toContain('100:202:300')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
114
tests-ui/tests/utils/nodeFilterUtil.test.ts
Normal file
114
tests-ui/tests/utils/nodeFilterUtil.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterOutputNodes, isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
|
||||
describe('nodeFilterUtil', () => {
|
||||
// Helper to create a mock node
|
||||
const createMockNode = (
|
||||
id: number,
|
||||
isOutputNode: boolean = false
|
||||
): LGraphNode => {
|
||||
// Create a custom class with the nodeData static property
|
||||
class MockNode extends LGraphNode {
|
||||
static nodeData = isOutputNode ? { output_node: true } : {}
|
||||
}
|
||||
|
||||
const node = new MockNode('')
|
||||
node.id = id
|
||||
return node
|
||||
}
|
||||
|
||||
describe('filterOutputNodes', () => {
|
||||
it('should return empty array when given empty array', () => {
|
||||
const result = filterOutputNodes([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should filter out non-output nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode(1, false),
|
||||
createMockNode(2, true),
|
||||
createMockNode(3, false),
|
||||
createMockNode(4, true)
|
||||
]
|
||||
|
||||
const result = filterOutputNodes(nodes)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((n) => n.id)).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('should return all nodes if all are output nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode(1, true),
|
||||
createMockNode(2, true),
|
||||
createMockNode(3, true)
|
||||
]
|
||||
|
||||
const result = filterOutputNodes(nodes)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toEqual(nodes)
|
||||
})
|
||||
|
||||
it('should return empty array if no output nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode(1, false),
|
||||
createMockNode(2, false),
|
||||
createMockNode(3, false)
|
||||
]
|
||||
|
||||
const result = filterOutputNodes(nodes)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle nodes without nodeData', () => {
|
||||
// Create a plain LGraphNode without custom constructor
|
||||
const node = new LGraphNode('')
|
||||
node.id = 1
|
||||
|
||||
const result = filterOutputNodes([node])
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle nodes with undefined output_node', () => {
|
||||
class MockNodeWithOtherData extends LGraphNode {
|
||||
static nodeData = { someOtherProperty: true }
|
||||
}
|
||||
|
||||
const node = new MockNodeWithOtherData('')
|
||||
node.id = 1
|
||||
|
||||
const result = filterOutputNodes([node])
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOutputNode', () => {
|
||||
it('should filter selected nodes to only output nodes', () => {
|
||||
const selectedNodes = [
|
||||
createMockNode(1, false),
|
||||
createMockNode(2, true),
|
||||
createMockNode(3, false),
|
||||
createMockNode(4, true),
|
||||
createMockNode(5, false)
|
||||
]
|
||||
|
||||
const result = selectedNodes.filter(isOutputNode)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((n) => n.id)).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('should handle empty selection', () => {
|
||||
const emptyNodes: LGraphNode[] = []
|
||||
const result = emptyNodes.filter(isOutputNode)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle selection with no output nodes', () => {
|
||||
const selectedNodes = [createMockNode(1, false), createMockNode(2, false)]
|
||||
|
||||
const result = selectedNodes.filter(isOutputNode)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user