Compare commits

..

29 Commits

Author SHA1 Message Date
bymyself
c7f297c24b chore: fix knip config hint and sync lockfile after rebase 2026-04-18 21:47:14 -07:00
bymyself
c6de167fe9 fix: address review nitpicks in test-recorder
- Guard box() against empty lines array (RangeError on Math.max)
- Sanitize testName in recording template to prevent injection
2026-04-18 21:45:45 -07:00
bymyself
6b02aab81a test: add unit tests for test-recorder transform and improve README
- Add 25 colocated unit tests for transform rules and engine
- Add tools/ to vitest include pattern
- Improve README with quick-start prereqs and test instructions
2026-04-18 21:45:45 -07:00
GitHub Action
7fd35c2f82 [automated] Apply ESLint and Oxfmt fixes 2026-04-18 21:45:45 -07:00
bymyself
0e5d788688 fix: add entry config for test-recorder in knip
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032682
2026-04-18 21:45:45 -07:00
bymyself
2d0fbe3822 fix: check both quote styles for Playwright import detection
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032677
2026-04-18 21:45:45 -07:00
bymyself
478b39cdc5 fix: remove misleading packageManager field from test-recorder
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032674
2026-04-18 21:45:45 -07:00
bymyself
61a6b402ae fix: remove dead wait() function from logger
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032673
2026-04-18 21:45:45 -07:00
bymyself
fb98dfd736 fix: remove blanket oxlint exclusion for tools/test-recorder
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032669
2026-04-18 21:45:45 -07:00
bymyself
cee63169ba fix: pin @playwright/mcp to 0.0.68 for reproducibility
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032667
2026-04-18 21:45:45 -07:00
bymyself
92561d2f5c fix: add _recording-session.spec.ts to .gitignore
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032664
2026-04-18 21:45:45 -07:00
bymyself
1f80ca56e5 fix: prevent replace-bare-page from matching inside comfyPage.page.
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032663
2026-04-18 21:45:45 -07:00
bymyself
3e3625bf95 fix: refactor codegen-transform skill to reference existing docs
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005775537
2026-04-18 21:45:45 -07:00
bymyself
a1aeb5254f fix: remove AI-Assisted Test Creation section from browser_tests/AGENTS.md
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005774857
2026-04-18 21:45:45 -07:00
GitHub Action
ef44acf16d [automated] Apply ESLint and Oxfmt fixes 2026-04-18 21:45:45 -07:00
bymyself
fded789d9f style: format .mcp.json and gh.ts with oxfmt 2026-04-18 21:45:45 -07:00
bymyself
2c01be33bd fix: cross-platform root detection and subprocess error handling
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550590
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550591
2026-04-18 21:45:45 -07:00
bymyself
f1a058ea4f fix: use path.basename for cross-platform path inference in transform
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550584
2026-04-18 21:45:45 -07:00
bymyself
b9a4f86713 fix: check pnpm install status and handle clipboard failure
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550581
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550583
2026-04-18 21:45:45 -07:00
bymyself
618c32416f fix: escape user input in generated test.describe string literals
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550593
2026-04-18 21:45:45 -07:00
bymyself
fd02ebcf26 fix: set non-zero exit code on check failure and remove unimplemented --version
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550585
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550586
2026-04-18 21:45:45 -07:00
bymyself
b2f96cc3c0 fix: escape single quotes in workflow name for template generation
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550592
2026-04-18 21:45:45 -07:00
bymyself
58135fed67 fix: use @playwright/mcp package for MCP server config
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550575
2026-04-18 21:45:45 -07:00
bymyself
2f56d6727d fix: document planner/generator/healer agent roles in AGENTS.md
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550576
2026-04-18 21:45:45 -07:00
bymyself
e5a967f3a7 fix: guard against missing agent files in patch script
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550578
2026-04-18 21:45:45 -07:00
bymyself
ff06060689 fix: require escalation criteria before test.fixme() in healer agent
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550573
2026-04-18 21:45:45 -07:00
bymyself
0d9171e8a3 fix: pin corepack pnpm version to match repo lockfile
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550580
2026-04-18 21:45:45 -07:00
bymyself
5e570c85a8 fix: add required file argument to transform usage example
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550579
2026-04-18 21:45:45 -07:00
bymyself
622f9a8238 feat: add Playwright test agents system — interactive recorder CLI, AI agents, and codegen transform
Three integrated systems for AI-assisted browser test creation:

1. comfy-test CLI (tools/test-recorder/)
   - Interactive 7-step recording flow for QA testers and non-developers
   - Environment checks with platform-specific install guidance
   - Codegen-to-convention transform engine
   - PR creation via gh CLI or manual GitHub web UI instructions
   - Commands: record, transform, check, list

2. Playwright AI agents (.claude/agents/)
   - Planner, generator, and healer agents patched with ComfyUI context
   - Regeneration scripts for Playwright updates (scripts/update-playwright-agents.sh)
   - MCP server config (.mcp.json) for agent browser interaction
   - Seed test and specs directory for agent-generated tests

3. Codegen transform skill (.claude/skills/codegen-transform/)
   - Transform rules: @playwright/test → comfyPageFixture, page → comfyPage,
     remove goto, canvas locators, waitForTimeout → nextFrame
   - Structural transforms: wrap in describe with tags, add afterEach cleanup
   - Fixture API reference and before/after examples
2026-04-18 21:45:45 -07:00
170 changed files with 3565 additions and 10997 deletions

View File

@@ -0,0 +1,125 @@
---
name: playwright-test-generator
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
model: sonnet
color: blue
---
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
application behavior.
# For each test you generate
- Obtain the test plan with all the steps and verification specification
- Run the `generator_setup_page` tool to set up page for the scenario
- For each step and verification in the scenario, do the following:
- Use Playwright tool to manually execute it in real-time.
- Use the step description as the intent for each Playwright tool call.
- Retrieve generator log via `generator_read_log`
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
- File should contain single test
- File name must be fs-friendly scenario name
- Test must be placed in a describe matching the top-level test plan item
- Test title must match the scenario name
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
multiple actions.
- Always use best practices from the log when generating tests.
<example-generation>
For following plan:
```markdown file=specs/plan.md
### 1. Adding New Todos
**Seed:** `tests/seed.spec.ts`
#### 1.1 Add Valid Todo
**Steps:**
1. Click in the "What needs to be done?" input field
#### 1.2 Add Multiple Todos
...
```
Following file is generated:
```ts file=add-valid-todo.spec.ts
// spec: specs/plan.md
// seed: tests/seed.spec.ts
test.describe('Adding New Todos', () => {
test('Add Valid Todo', async { page } => {
// 1. Click in the "What needs to be done?" input field
await page.click(...);
...
});
});
```
</example-generation>
## ComfyUI Project Context
### Required Import Pattern
Generated tests MUST use ComfyUI fixtures, not generic `@playwright/test`:
```typescript
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
```
### Fixture Object
Tests receive `comfyPage` (not `page`) as their fixture:
```typescript
test('my test', async ({ comfyPage }) => {
// Access raw page via comfyPage.page if needed
})
```
### Key APIs
| Need | Use | Notes |
| ---------------- | ---------------------------------------------------- | --------------------------------- |
| Canvas element | `comfyPage.canvas` | Pre-configured Locator |
| Wait for render | `comfyPage.nextFrame()` | After canvas mutations |
| Load workflow | `comfyPage.workflow.loadWorkflow('name')` | Assets in `browser_tests/assets/` |
| Get node by type | `comfyPage.nodeOps.getNodeRefsByType('KSampler')` | Returns NodeReference[] |
| Search box | `comfyPage.searchBox.fillAndSelectFirstNode('name')` | Opens on canvas dblclick |
| Settings | `comfyPage.settings.setSetting(key, value)` | Clean up in afterEach |
| Keyboard | `comfyPage.keyboard.press('Delete')` | Focus canvas first |
| Context menu | `comfyPage.contextMenu` | Right-click interactions |
### Mandatory Test Structure
Every generated test must:
1. Be wrapped in `test.describe('Name', { tag: ['@canvas'] }, () => { ... })`
2. Include `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })`
3. Use descriptive test names (not "test" or "test1")
### Anti-Patterns — NEVER Use
- ❌ `page.goto()` — fixture handles navigation
- ❌ `page.waitForTimeout()` — use `comfyPage.nextFrame()` or retrying assertions
- ❌ `import from '@playwright/test'` — use `from '../fixtures/ComfyPage'`
- ❌ Bare `page.` references — use `comfyPage.page.` if you need raw page access
### Reference
Read the fixture code for full API surface:
- `browser_tests/fixtures/ComfyPage.ts` — main fixture
- `browser_tests/fixtures/helpers/` — helper classes
- `browser_tests/fixtures/components/` — page object components
- See also: `.claude/skills/codegen-transform/SKILL.md` for transform rules

View File

@@ -0,0 +1,91 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests
tools: Glob, Grep, Read, LS, Edit, MultiEdit, Write, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
model: sonnet
color: red
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
2. **Debug failed tests**: For each failing test run `test_debug`.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high confidence the test is correct, do not auto-skip by default.
- Summarize root-cause evidence and escalate as a likely app regression.
- Use `test.fixme()` only when a known issue is documented and referenced, and include a short rationale comment.
Auto-skipping can mask real regressions — require explicit justification.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis
## ComfyUI Project Context
### Custom Fixtures
Tests in this project use `comfyPage` fixture, not bare `page`. When healing:
- Replace any `page.` references with `comfyPage.page.` if adding new code
- Use `comfyPage.nextFrame()` instead of adding `waitForTimeout()`
- Use fixture helpers (`comfyPage.nodeOps`, `comfyPage.canvas`, etc.) over raw locators
### Common Failure Causes in ComfyUI Tests
1. **Missing `nextFrame()`**: Canvas operations need `await comfyPage.nextFrame()` after mutations. This is the #1 cause of "works locally, fails in CI" issues.
2. **Canvas focus required**: Keyboard shortcuts won't work unless `await comfyPage.canvas.click()` is called first.
3. **Node position drift**: Pixel coordinates can shift between environments. When possible, replace with node references:
```typescript
// Instead of: canvas.click({ position: { x: 423, y: 267 } })
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await node.click('title')
```
4. **Settings pollution**: Settings persist across tests on the backend. Always reset changed settings in `afterEach`.
5. **Drag animation timing**: Use `{ steps: 10 }` option for drag operations, not `{ steps: 1 }`.
### Healing Safety Rules
- ❌ NEVER add `waitForTimeout()` — always use retrying assertions or `nextFrame()`
- ❌ NEVER "fix" a test by weakening assertions (e.g., removing an assertion that fails)
- ❌ NEVER modify the application code — only modify test code
- ⚠️ If a test fails because expected UI elements are missing, the app may have a regression — mark as `test.fixme()` with explanation, don't "heal" the assertion away
- ⚠️ If a test fails only in CI but passes locally, likely missing `nextFrame()` — don't mask with timeouts
### Reference
- `browser_tests/fixtures/ComfyPage.ts` — full fixture API
- `browser_tests/fixtures/helpers/` — available helper classes
- `.claude/skills/writing-playwright-tests/SKILL.md` — testing conventions
- `.claude/skills/codegen-transform/SKILL.md` — transform rules

View File

@@ -0,0 +1,89 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_run_code, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page, mcp__playwright-test__planner_save_plan
model: sonnet
color: green
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use `browser_*` tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Submit your test plan using `planner_save_plan` tool.
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.
## ComfyUI Project Context
### Application Overview
ComfyUI is a **canvas-based node graph editor** for AI image generation. It is a complex SPA with:
- A **LiteGraph canvas** where users create workflows by connecting nodes
- A **Vue 3 sidebar** with node library, workflows panel, and settings
- A **topbar** with queue/run buttons and workspace controls
- A **search box** for finding and adding nodes (opens on double-click)
- WebSocket-based real-time communication with a Python backend
### Exploration Tips
- Start by loading a workflow: the app is most useful with nodes on the canvas
- Key UI areas to explore: canvas interactions, sidebar panels, topbar buttons, search box, context menus, settings dialog
- Double-click the canvas to open the node search box
- Right-click nodes/canvas for context menus
- The bottom panel shows job queue and execution logs
### Test Environment
- The seed test uses `comfyPageFixture` which provides a `comfyPage` object with extensive helpers
- Workflows (JSON files) are loaded via `comfyPage.workflow.loadWorkflow('name')`
- Available workflow assets are in `browser_tests/assets/`
- The backend MUST be running with `--multi-user` flag
- A Vite dev server runs on `:5173`
### When Creating Test Plans
- Reference specific workflow assets when a scenario needs a starting state
- Note that canvas interactions use pixel coordinates — these may vary across environments
- Distinguish between "canvas tests" (LiteGraph) and "UI tests" (Vue components)
- Include tags in your plans: `@canvas`, `@widget`, `@sidebar`, `@smoke`, `@screenshot`
- Reference `browser_tests/fixtures/ComfyPage.ts` for available test helpers

View File

@@ -0,0 +1,95 @@
---
name: codegen-transform
description: 'Transforms raw Playwright codegen output into ComfyUI convention-compliant tests. Use when: user pastes raw codegen, asks to convert raw Playwright code, refactor recorded tests, or rewrite to project conventions. Triggers on: transform codegen, convert raw test, rewrite to conventions, codegen output, raw playwright.'
---
# Codegen → Convention Transform
Transform raw Playwright codegen output into tests that follow ComfyUI conventions.
## When to Use
- QA tester recorded a test with `pnpm comfy-test record` and wants refinement
- Developer pasted raw `npx playwright codegen` output
- Agent needs to post-process Playwright test agent output
- Reviewing a test that uses raw `page.*` calls instead of fixture helpers
## Reference Documentation
Before transforming, read these existing docs for full context:
| Document | What it covers |
| ------------------------------------- | ----------------------------------------------------------------------- |
| `docs/guidance/playwright.md` | Playwright conventions, type assertions, assertion best practices, tags |
| `browser_tests/AGENTS.md` | Directory structure, polling assertions, gotchas, quality checks |
| `browser_tests/fixtures/ComfyPage.ts` | Main fixture API (source of truth for all helpers) |
| `browser_tests/fixtures/helpers/` | Focused helper classes (canvas, keyboard, workflow, etc.) |
## Transform Rules
The programmatic transform engine lives in `tools/test-recorder/src/transform/rules.ts`. Apply these replacements in order:
| Raw codegen | Convention replacement | Why |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- |
| `import { test, expect } from '@playwright/test'` | `import { comfyPageFixture as test, comfyExpect as expect } from '../fixtures/ComfyPage'` | Use custom fixtures with ComfyUI helpers |
| `test('test', async ({ page }) =>` | `test('descriptive-name', async ({ comfyPage }) =>` | Use comfyPage fixture, descriptive names |
| `await page.goto('http://...')` | **Remove entirely** | Fixture handles navigation automatically |
| `page.locator('canvas')` | `comfyPage.canvas` | Pre-configured canvas locator |
| `page.waitForTimeout(N)` | `comfyPage.nextFrame()` | Never use arbitrary waits |
| `page.getByPlaceholder('Search Nodes...')` | `comfyPage.searchBox.input` | Use search box page object |
| `page` (bare reference) | `comfyPage.page` | Access raw page through fixture |
| Bare `test(...)` | `test.describe('Feature', { tag: ['@canvas'] }, () => { test(...) })` | All tests need describe + tags |
| No cleanup | Add `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })` | Canvas tests need cleanup |
## Canvas Coordinates → Node References
Raw codegen records fragile pixel coordinates. Replace with node references when possible:
```typescript
// ❌ Raw codegen — fragile pixel coordinates
await page.locator('canvas').click({ position: { x: 423, y: 267 } })
// ✅ If clicking a specific node
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await node.click('title')
// ✅ If double-clicking canvas to open search
await comfyPage.canvas.dblclick({ position: { x: 500, y: 400 } })
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
```
**When to keep coordinates**: Canvas background clicks (pan, zoom), empty area clicks to deselect. These are inherently position-based.
## Decision Guide
| Question | Answer |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| Canvas or DOM interaction? | Canvas: `comfyPage.nodeOps.*`. DOM: `comfyPage.vueNodes.*` (needs opt-in) |
| Need `nextFrame()`? | Yes after canvas mutations. No after `loadWorkflow()`, no after DOM clicks |
| Which tag? | `@canvas` for canvas tests, `@widget` for widget tests, `@screenshot` for visual regression |
| Need cleanup? | Yes for canvas tests (`resetView`), yes if changing settings (`setSetting` back) |
| Keep pixel coords? | Only for empty canvas clicks. Replace with node refs for node interactions |
| Use `page` directly? | Only via `comfyPage.page` for Playwright APIs not wrapped by fixtures |
## Anti-Patterns
1. **Never use `waitForTimeout`** → use `nextFrame()` or retrying assertions
2. **Never use `page.goto`** → fixture handles navigation
3. **Never import from `@playwright/test`** → use `../fixtures/ComfyPage`
4. **Never use bare CSS selectors** → use test IDs or semantic locators
5. **Never share state between tests** → each test is independent
6. **Never commit local screenshots** → Linux CI generates baselines
## For Deeper Reference
Read fixture code directly — it's the source of truth:
| Purpose | Path |
| ----------------- | ------------------------------------------ |
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
| Helper classes | `browser_tests/fixtures/helpers/` |
| Component objects | `browser_tests/fixtures/components/` |
| Test selectors | `browser_tests/fixtures/selectors.ts` |
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
| Existing tests | `browser_tests/tests/` |
| Test assets | `browser_tests/assets/` |

View File

@@ -1,88 +0,0 @@
name: Resolve PR from workflow_run
description: >
Resolves the PR number from a workflow_run event using pull_requests[0]
with a listPullRequestsAssociatedWithCommit fallback.
Skips closed/merged PRs and stale runs (head SHA mismatch).
inputs:
token:
description: GitHub token for API calls
required: false
default: ${{ github.token }}
outputs:
skip:
description: "'true' when no open PR was found or the run is stale"
value: ${{ steps.resolve.outputs.skip }}
number:
description: The PR number (empty when skip is true)
value: ${{ steps.resolve.outputs.number }}
base:
description: The PR base branch (empty when skip is true)
value: ${{ steps.resolve.outputs.base }}
head-sha:
description: The PR head SHA (empty when skip is true)
value: ${{ steps.resolve.outputs.head-sha }}
runs:
using: composite
steps:
- name: Resolve PR
id: resolve
uses: actions/github-script@v8
with:
github-token: ${{ inputs.token }}
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
// Fork PRs: pull_requests is empty and commit SHA may not be in
// the base repo graph. Fall back to pulls.list with head filter.
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
per_page: 1,
});
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.state !== 'open') {
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
core.setOutput('skip', 'true');
return;
}
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
);
core.setOutput('skip', 'true');
return;
}
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));

View File

@@ -58,6 +58,21 @@ jobs:
retention-days: 30
if-no-files-found: warn
- name: Save PR metadata
if: github.event_name == 'pull_request'
run: |
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v6
with:
name: perf-meta
path: temp/perf-meta/
- name: Save perf baseline to perf-data branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
continue-on-error: true

View File

@@ -32,6 +32,13 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6
with:

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: reports
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -72,6 +85,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -69,6 +82,6 @@ jobs:
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -18,12 +18,6 @@ on:
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
@@ -31,8 +25,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -40,83 +32,28 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build
- name: Fetch head commit metadata
id: head-commit
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
})
const author = data.author?.login || data.commit.author?.name || ''
const message = (data.commit.message || '').split('\n', 1)[0]
core.setOutput('author', author)
core.setOutput('message', message)
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
env:
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
GIT_PR_ID: ${{ github.event.pull_request.number }}
GIT_REPO: ${{ github.repository }}
run: |
URL=$(vercel deploy --prebuilt \
--meta githubCommitRef="$GIT_COMMIT_REF" \
--meta githubCommitSha="$GIT_COMMIT_SHA" \
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
--meta githubPrId="$GIT_PR_ID" \
--meta githubRepo="$GIT_REPO")
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Alias deployment to stable PR hostname
id: alias-set
continue-on-error: true
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
run: |
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
- name: Add deployment URL to summary
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
- name: Publish preview outputs
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
- name: Save PR metadata
run: |
if [[ "$ALIAS_OK" == "true" ]]; then
STABLE_URL="https://$ALIAS_HOST"
else
STABLE_URL="$DEPLOY_URL"
fi
mkdir -p temp/vercel-preview
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
{
echo "**Preview:** $STABLE_URL"
if [[ "$ALIAS_OK" == "true" ]]; then
echo "**This commit:** $DEPLOY_URL"
else
echo "_Stable alias update failed — URL reflects this commit only._"
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
- name: Upload preview metadata
uses: actions/upload-artifact@v6
@@ -134,24 +71,19 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --prod
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod)
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary

View File

@@ -30,7 +30,42 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
- name: Find size workflow run
if: steps.pr-meta.outputs.skip != 'true'

View File

@@ -12,10 +12,6 @@ permissions:
pull-requests: write
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
@@ -35,24 +31,38 @@ jobs:
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
if: steps.pr-meta.outputs.skip != 'true'
id: meta
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
env:
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
SHORT_SHA="${HEAD_SHA:0:7}"
cat > preview-report.md <<EOF
**Website Preview:** $STABLE_URL
<sub>This commit: $UNIQUE_URL</sub>
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
EOF
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'

1
.gitignore vendored
View File

@@ -59,6 +59,7 @@ coverage/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/local/
browser_tests/tests/_recording-session.spec.ts
.env

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright-test": {
"command": "pnpm",
"args": ["dlx", "@playwright/mcp@0.0.68"]
}
}
}

View File

@@ -10,6 +10,7 @@
"dist/*",
"packages/registry-types/src/comfyRegistryTypes.ts",
"playwright-report/*",
"scripts/patch-playwright-agents.js",
"src/extensions/core/*",
"src/scripts/*",
"src/types/generatedManagerTypes.ts",
@@ -115,6 +116,12 @@
"no-console": "allow"
}
},
{
"files": ["tools/test-recorder/**/*.ts"],
"rules": {
"no-console": "allow"
}
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],

View File

@@ -44,12 +44,26 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm comfy-test record`: Interactive test recorder (guided setup for non-devs)
- `pnpm comfy-test transform <file>`: Transform raw codegen to conventions
- `pnpm comfy-test check`: Check environment prerequisites
- `pnpm comfy-test list`: List available test workflows
### Playwright Test Agents (`.claude/agents/`)
| Agent | Responsibility |
| ------------------------------ | ---------------------------------------------------------------------------------- |
| `playwright-test-planner.md` | Explores the app, identifies testable scenarios, creates structured test plans |
| `playwright-test-generator.md` | Generates Playwright test code from plans using ComfyUI fixtures and conventions |
| `playwright-test-healer.md` | Diagnoses and fixes failing tests; escalates regressions rather than auto-skipping |
Guardrails: agents must use `comfyPage` fixture (not bare `page`), never add `waitForTimeout()`, never weaken assertions, and reference `.claude/skills/codegen-transform/SKILL.md` for transform rules.
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm typecheck`: Vue TSC type checking

View File

@@ -23,10 +23,6 @@
"destination": "https://blog.comfy.org/",
"permanent": true
},
{
"source": "/press",
"destination": "/about",
"permanent": true
}
{ "source": "/press", "destination": "/about", "permanent": true }
]
}

View File

@@ -70,6 +70,32 @@ await comfyPage.setup({ mockReleases: false })
For tests that specifically need to test release functionality, see the example in `tests/releaseNotifications.spec.ts`.
## Recording Tests (For Non-Developers)
If you're a QA tester or non-developer, use the interactive recorder:
```bash
pnpm comfy-test record
```
This guides you through a 7-step flow:
1. **Environment check** — verifies all tools are installed (with install instructions if not)
2. **Project setup** — installs dependencies
3. **Backend check** — ensures ComfyUI is running
4. **Configure** — set test name, tags, and starting workflow
5. **Record** — opens browser with Playwright Inspector for recording
6. **Transform** — paste recorded code, auto-transforms to project conventions
7. **PR creation** — creates a PR via `gh` CLI or gives manual instructions
Other commands:
```bash
pnpm comfy-test check # Just run environment checks
pnpm comfy-test transform <file> # Transform a raw codegen file
pnpm comfy-test list # List available workflow assets
```
## Running Tests
**Always use UI mode for development:**

View File

@@ -1,116 +0,0 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -30,6 +30,8 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
@@ -177,6 +179,7 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
@@ -230,6 +233,7 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
@@ -495,6 +499,7 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

@@ -1,16 +0,0 @@
import { test as base } from '@playwright/test'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
assetApi: async ({ page }, use) => {
const assetApi = createAssetHelper(page)
await use(assetApi)
await assetApi.clearMocks()
}
})

View File

@@ -1,7 +1,5 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
class ShortcutsTab {
readonly essentialsTab: Locator
readonly viewControlsTab: Locator
@@ -18,26 +16,6 @@ class ShortcutsTab {
}
}
export class LogsTab {
readonly tab: Locator
readonly terminalRoot: Locator
readonly terminalHost: Locator
readonly copyButton: Locator
readonly errorMessage: Locator
readonly loadingSpinner: Locator
readonly xtermScreen: Locator
constructor(readonly page: Page) {
this.tab = page.getByRole('tab', { name: /Logs/i })
this.terminalRoot = page.getByTestId(TestIds.terminal.root)
this.terminalHost = page.getByTestId(TestIds.terminal.host)
this.copyButton = page.getByTestId(TestIds.terminal.copyButton)
this.errorMessage = page.getByTestId(TestIds.terminal.errorMessage)
this.loadingSpinner = page.getByTestId(TestIds.terminal.loadingSpinner)
this.xtermScreen = this.terminalHost.locator('.xterm-screen')
}
}
export class BottomPanel {
readonly root: Locator
readonly keyboardShortcutsButton: Locator
@@ -45,7 +23,6 @@ export class BottomPanel {
readonly closeButton: Locator
readonly resizeGutter: Locator
readonly shortcuts: ShortcutsTab
readonly logs: LogsTab
constructor(readonly page: Page) {
this.root = page.locator('.bottom-panel')
@@ -61,15 +38,6 @@ export class BottomPanel {
'.splitter-overlay-bottom > .p-splitter-gutter'
)
this.shortcuts = new ShortcutsTab(page)
this.logs = new LogsTab(page)
}
async toggleLogs() {
await this.toggleButton.click()
await this.logs.tab.waitFor({ state: 'visible' })
if ((await this.logs.tab.getAttribute('aria-selected')) !== 'true') {
await this.logs.tab.click()
}
}
async resizeByDragging(deltaY: number): Promise<void> {

View File

@@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = {
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py'],
argv: ['main.py', '--listen', '0.0.0.0'],
ram_total: 67108864000,
ram_free: 52428800000
},

View File

@@ -1,184 +0,0 @@
import type { Locator, Page, Route } from '@playwright/test'
import type { components } from '@comfyorg/registry-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ReleaseNote = components['schemas']['ReleaseNote']
export type HelpMenuItemKey =
| 'feedback'
| 'help'
| 'docs'
| 'discord'
| 'github'
| 'manager'
| 'update-comfyui'
| 'more'
export class HelpCenterHelper {
public readonly button: Locator
public readonly popup: Locator
public readonly backdrop: Locator
public readonly whatsNewSection: Locator
constructor(public readonly page: Page) {
this.button = page.getByTestId(TestIds.helpCenter.button)
this.popup = page.getByTestId(TestIds.helpCenter.popup)
this.backdrop = page.getByTestId(TestIds.helpCenter.backdrop)
this.whatsNewSection = page.getByTestId(TestIds.dialogs.whatsNewSection)
}
menuItem(key: HelpMenuItemKey): Locator {
return this.page.getByTestId(TestIds.helpCenter.menuItem(key))
}
releaseItem(version: string): Locator {
return this.page.getByTestId(TestIds.helpCenter.releaseItem(version))
}
get releaseItems(): Locator {
return this.whatsNewSection.locator('[data-testid^="help-release-item-"]')
}
async open(): Promise<void> {
await this.button.waitFor({ state: 'visible' })
await this.button.click()
await this.popup.waitFor({ state: 'visible' })
}
async closeViaBackdrop(): Promise<void> {
await this.backdrop.click()
await this.popup.waitFor({ state: 'hidden' })
}
async toggle(): Promise<void> {
await this.button.click()
}
/**
* Mock the Comfy release API so the help center gets a deterministic
* list of releases. Empty array is used when `releases` is omitted.
*/
async mockReleases(releases: ReleaseNote[] = []): Promise<void> {
await this.page.route('**/releases**', async (route: Route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(releases)
})
} else {
await route.continue()
}
})
}
/**
* Intercept the Zendesk support URL so it never actually loads in the
* new tab opened by the Contact Support command.
*/
async stubSupportPage(): Promise<void> {
await this.page
.context()
.route('https://support.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
/**
* Intercept the docs.comfy.org changelog / guide pages so new tabs opened
* by help center actions don't hit the real site during tests.
*/
async stubDocsPage(): Promise<void> {
await this.page
.context()
.route('https://docs.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
/**
* Intercept outbound static URLs (discord, github, ...) so new tabs
* opened by help center actions don't navigate to the real sites.
*/
async stubExternalPages(): Promise<void> {
for (const pattern of [
'https://www.comfy.org/**',
'https://github.com/**'
]) {
await this.page.context().route(pattern, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
}
}
/**
* Arms the `popup` listener, runs the action that triggers `window.open`,
* then waits for the popup's initial navigation to commit so `popup.url()`
* doesn't race and return `about:blank`. Returns a parsed `URL` and closes
* the popup.
*
* @example
* ```ts
* const url = await waitForPopup(page, () => button.click())
* expect(url.hostname).toBe('example.com')
* ```
*/
export async function waitForPopup(
page: Page,
action: () => Promise<void>
): Promise<URL> {
const popupPromise = page.waitForEvent('popup')
await action()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const url = new URL(popup.url())
await popup.close()
return url
}
export function createMockRelease(
overrides: Partial<ReleaseNote> = {}
): ReleaseNote {
return {
id: 1,
project: 'comfyui',
version: '0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString(),
...overrides
}
}
/**
* Extends the main comfyPageFixture so that depending on `helpCenter`
* automatically boots the full Comfy app (via the underlying comfyPage
* fixture's setup). Tests only need to destructure `helpCenter`.
*/
export const helpCenterFixture = comfyPageFixture.extend<{
helpCenter: HelpCenterHelper
}>({
helpCenter: async ({ comfyPage }, use) => {
await use(new HelpCenterHelper(comfyPage.page))
}
})

View File

@@ -1,75 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
let resolve!: () => void
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return resolve
}
async mockRawLogsError() {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {
return JSON.stringify({
type: 'logs',
data: { entries: LogsTerminalHelper.buildEntries(messages) }
})
}
private static buildRawLogsResponse(messages: string[]): LogsRawResponse {
return {
size: { cols: 80, row: 24 },
entries: LogsTerminalHelper.buildEntries(messages)
}
}
private static buildEntries(messages: string[]) {
return messages.map((m) => ({
t: '1970-01-01T00:00:00.000Z',
m: m.endsWith('\n') ? m : `${m}\n`
}))
}
}
export const logsTerminalFixture = base.extend<{
logsTerminal: LogsTerminalHelper
}>({
logsTerminal: async ({ page }, use) => {
await use(new LogsTerminalHelper(page))
}
})

View File

@@ -1,10 +1,7 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -123,27 +120,6 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -226,13 +202,3 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -1,95 +0,0 @@
import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
export type { CanvasRect }
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
const isLegacyCanvasNodeWithoutVueDom = !nodeEl
if (isLegacyCanvasNodeWithoutVueDom) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (!node) {
throw new Error(`Node ${id} not found in graph`)
}
const rect = node.boundingRect
nodeVisualBounds[id] = {
x: rect[0],
y: rect[1],
w: rect[2],
h: rect[3]
}
continue
}
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -115,13 +115,6 @@ export const TestIds = {
menu: {
moreMenuContent: 'more-menu-content'
},
helpCenter: {
button: 'help-center-button',
popup: 'help-center-popup',
backdrop: 'help-center-backdrop',
menuItem: (key: string) => `help-menu-item-${key}`,
releaseItem: (version: string) => `help-release-item-${version}`
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -206,13 +199,6 @@ export const TestIds = {
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
terminal: {
root: 'terminal-root',
host: 'terminal-host',
copyButton: 'terminal-copy-button',
errorMessage: 'terminal-error-message',
loadingSpinner: 'terminal-loading-spinner'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
@@ -225,11 +211,34 @@ export const TestIds = {
}
} as const
export type TestId<K extends keyof typeof TestIds> = Exclude<
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
(...args: never[]) => string
>
export type TestIdValue = {
[K in keyof typeof TestIds]: TestId<K>
}[keyof typeof TestIds]
/**
* Helper type for accessing nested TestIds (excludes function values)
*/
export type TestIdValue =
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]

View File

@@ -4,7 +4,6 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
return {
@@ -332,22 +331,6 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/**
* Toggle the node's collapsed state by simulating the same user interaction
* the runtime uses: DOM collapse button click in Vue mode, canvas icon click
* in legacy mode. Mode is detected by the presence of a Vue-rendered DOM
* element with `data-node-id`.
*/
async toggleCollapse() {
const vueLocator = this.comfyPage.page.locator(
`[data-node-id="${this.id}"]`
)
if ((await vueLocator.count()) > 0) {
await new VueNodeFixture(vueLocator).toggleCollapse()
return
}
await this.click('collapse')
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -0,0 +1,3 @@
# Specs
This is a directory for test plans.

View File

@@ -1,7 +1,6 @@
import { expect, mergeTests } from '@playwright/test'
import { expect } from '@playwright/test'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
@@ -18,8 +17,6 @@ import {
STABLE_OUTPUT
} from '@e2e/fixtures/data/assetFixtures'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
@@ -69,7 +66,8 @@ test.describe('AssetHelper', () => {
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
@@ -89,12 +87,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({
comfyPage,
assetApi
}) => {
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
@@ -112,12 +110,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({
comfyPage,
assetApi
}) => {
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
@@ -131,12 +129,14 @@ test.describe('AssetHelper', () => {
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({
comfyPage,
assetApi
}) => {
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -175,12 +175,14 @@ test.describe('AssetHelper', () => {
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
@@ -191,12 +193,11 @@ test.describe('AssetHelper', () => {
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({
comfyPage,
assetApi
}) => {
test('POST /assets returns upload response', async ({ comfyPage }) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
@@ -204,6 +205,7 @@ test.describe('AssetHelper', () => {
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
@@ -215,12 +217,14 @@ test.describe('AssetHelper', () => {
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
@@ -231,14 +235,14 @@ test.describe('AssetHelper', () => {
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({
comfyPage,
assetApi
}) => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -261,12 +265,12 @@ test.describe('AssetHelper', () => {
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({
comfyPage,
assetApi
}) => {
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -276,14 +280,14 @@ test.describe('AssetHelper', () => {
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({
comfyPage,
assetApi
}) => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
@@ -292,14 +296,16 @@ test.describe('AssetHelper', () => {
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage,
assetApi
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()

View File

@@ -1,151 +1,98 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
LogsTerminalHelper,
logsTerminalFixture
} from '@e2e/fixtures/helpers/LogsTerminalHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import {
getClipboardText,
interceptClipboardWrite
} from '@e2e/helpers/clipboardSpy'
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test.describe('panel', () => {
test.beforeEach(async ({ logsTerminal }) => {
await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs([])
})
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
test('opens to Logs tab via toggle button', async ({ comfyPage }) => {
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.tab).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
})
test('closes via toggle button', async ({ comfyPage }) => {
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleButton.click()
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
test('switches from shortcuts to Logs tab', async ({ comfyPage }) => {
await comfyPage.bottomPanel.keyboardShortcutsButton.click()
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.tab).toBeVisible()
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeHidden()
})
await expect(bottomPanel.root).toBeHidden()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
})
test.describe('terminal', () => {
test.beforeEach(async ({ logsTerminal }) => {
await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs([])
})
test('should show Logs tab when terminal panel opens', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('shows loading spinner while logs are loading', async ({
comfyPage,
logsTerminal
}) => {
const resolveRaw = await logsTerminal.mockRawLogsPending()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
})
resolveRaw()
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
})
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
test('renders initial log entries from the raw-logs API', async ({
comfyPage,
logsTerminal
}) => {
const logLine = 'Hello from ComfyUI backend!'
await logsTerminal.mockRawLogs([logLine])
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeHidden()
})
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
logLine
)
})
test('should switch between shortcuts and terminal panels', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('appends log entries received via WebSocket', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
const ws = await getWebSocket()
const firstLine = 'First live log line'
const secondLine = 'Second live log line'
await bottomPanel.toggleButton.click()
ws.send(LogsTerminalHelper.buildWsLogFrame([firstLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
firstLine
)
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeHidden()
})
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
firstLine
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
secondLine
)
})
test('should persist Logs tab content in bottom panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('copy button copies terminal contents to clipboard', async ({
comfyPage,
logsTerminal
}) => {
const logLine = 'Copy me to the clipboard'
await logsTerminal.mockRawLogs([logLine])
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
logLine
)
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await interceptClipboardWrite(comfyPage.page)
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
await comfyPage.bottomPanel.logs.terminalRoot.hover()
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
await comfyPage.bottomPanel.logs.copyButton.click()
const xtermContainer = bottomPanel.root.locator('.xterm')
await expect(xtermContainer).toBeVisible()
})
await expect
.poll(() => getClipboardText(comfyPage.page))
.toContain(logLine)
})
test('should render xterm container in terminal panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
test('shows error message when raw-logs API fails', async ({
comfyPage,
logsTerminal
}) => {
await logsTerminal.mockRawLogsError()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await comfyPage.bottomPanel.toggleLogs()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
await expect(logsTab).toBeVisible()
await expect(comfyPage.bottomPanel.logs.errorMessage).toBeVisible()
await expect(comfyPage.bottomPanel.logs.errorMessage).toContainText(
'Unable to load logs'
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
const isAlreadyActive =
(await logsTab.getAttribute('aria-selected')) === 'true'
if (!isAlreadyActive) {
await logsTab.click()
}
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
await expect(xtermScreen.first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--enable-manager']
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {

View File

@@ -1,54 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('File input same-file reselection', () => {
test('should allow uploading the same file twice via LoadImage node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const loadImageNode = loadImageNodes[0]
const uploadWidget = await loadImageNode.getWidget(1)
const fileWidget = await loadImageNode.getWidget(0)
// First upload
const firstUpload = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
const firstChooser = comfyPage.page.waitForEvent('filechooser')
await uploadWidget.click()
await (
await firstChooser
).setFiles(comfyPage.assetPath('test_upload_image.png'))
await firstUpload
await expect
.poll(() => fileWidget.getValue(), {
message: 'First upload should set widget value'
})
.toContain('test_upload_image')
// Second upload of the SAME file — before the fix, the hidden input
// retained the previous value and onchange did not fire.
const secondUpload = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
const secondChooser = comfyPage.page.waitForEvent('filechooser')
await uploadWidget.click()
await (
await secondChooser
).setFiles(comfyPage.assetPath('test_upload_image.png'))
await secondUpload
await expect
.poll(() => fileWidget.getValue(), {
message: 'Second upload of the same file should still set widget value'
})
.toContain('test_upload_image')
})
})

View File

@@ -1,174 +0,0 @@
import { expect } from '@playwright/test'
import {
createMockRelease,
helpCenterFixture as test,
waitForPopup
} from '@e2e/fixtures/helpers/HelpCenterHelper'
test.describe('Help Center', () => {
test.describe('popup visibility', () => {
test('opens the popup and shows the backdrop when the sidebar button is clicked', async ({
helpCenter
}) => {
await helpCenter.toggle()
await expect(helpCenter.popup).toBeVisible()
await expect(helpCenter.backdrop).toBeVisible()
})
test('closes when the backdrop is clicked', async ({ helpCenter }) => {
await helpCenter.open()
await helpCenter.closeViaBackdrop()
await expect(helpCenter.popup).toBeHidden()
})
test('closes after clicking a menu item that opens an external tab', async ({
helpCenter
}) => {
await helpCenter.stubDocsPage()
await helpCenter.open()
await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('docs').click()
)
await expect(helpCenter.popup).toBeHidden()
})
})
test.describe('popup positioning', () => {
test('anchors to the left when sidebar location is left', async ({
comfyPage,
helpCenter
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await helpCenter.open()
await expect(helpCenter.popup).toHaveClass(/sidebar-left/)
await expect(helpCenter.popup).not.toHaveClass(/sidebar-right/)
})
test('anchors to the right when sidebar location is right', async ({
comfyPage,
helpCenter
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await helpCenter.open()
await expect(helpCenter.popup).toHaveClass(/sidebar-right/)
await expect(helpCenter.popup).not.toHaveClass(/sidebar-left/)
})
})
test.describe('menu item actions', () => {
test.beforeEach(async ({ helpCenter }) => {
await helpCenter.stubDocsPage()
await helpCenter.stubExternalPages()
await helpCenter.stubSupportPage()
await helpCenter.open()
})
test('Docs item opens docs.comfy.org/ in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('docs').click()
)
expect(url.hostname).toBe('docs.comfy.org')
expect(url.pathname).toBe('/')
})
test('Discord item opens comfy.org/discord in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('discord').click()
)
expect(url.hostname).toBe('www.comfy.org')
expect(url.pathname).toBe('/discord')
})
test('Github item opens the ComfyUI repo in a new tab', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('github').click()
)
expect(url.hostname).toBe('github.com')
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
})
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('help').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
test('Give Feedback item opens Contact Support in OSS mode', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('feedback').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
})
test.describe("What's New releases", () => {
test('renders only the three most recent releases', async ({
comfyPage,
helpCenter
}) => {
const versions = ['0.4.10', '0.4.9', '0.4.8', '0.4.7', '0.4.6']
const now = Date.now()
const releases = versions.map((version, idx) =>
createMockRelease({
id: idx + 1,
version,
published_at: new Date(now - idx * 60_000).toISOString()
})
)
await helpCenter.mockReleases(releases)
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
await expect(helpCenter.whatsNewSection).toBeVisible()
await expect(helpCenter.releaseItems).toHaveCount(3)
await expect(helpCenter.releaseItem('0.4.10')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.9')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.8')).toBeVisible()
await expect(helpCenter.releaseItem('0.4.7')).toHaveCount(0)
})
test('clicking a release opens the changelog with a version anchor', async ({
comfyPage,
helpCenter
}) => {
const release = createMockRelease({ version: '0.3.50' })
await helpCenter.mockReleases([release])
await helpCenter.stubDocsPage()
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.releaseItem('0.3.50').click()
)
expect(url.hostname).toBe('docs.comfy.org')
expect(url.pathname).toBe('/changelog')
expect(url.hash).toBe('#v0-3-50')
await expect(helpCenter.popup).toBeHidden()
})
})
})

View File

@@ -0,0 +1,26 @@
/**
* Seed test for Playwright AI agents.
*
* This test bootstraps the ComfyUI environment for agent exploration.
* When agents (Planner, Generator, Healer) run, they execute this test
* first to set up the browser state, then use it as a template for
* generated tests.
*
* Usage:
* - Planner: Runs this to explore the app, then generates a test plan
* - Generator: Uses this as an import/fixture template
* - Healer: Runs this to establish baseline state
*/
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test('seed', async ({ comfyPage }) => {
// Load the default workflow — gives agents a realistic starting state
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Verify the app is ready
await expect(comfyPage.canvas).toBeVisible()
})

View File

@@ -1,235 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
type Layout = { ref: [number, number]; target: [number, number] }
const LAYOUTS: Record<string, Layout> = {
'bottom-left': { ref: [200, 100], target: [150, 500] },
'bottom-right': { ref: [100, 100], target: [600, 500] }
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
async function toggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
await comfyPage.keyboard.bypass()
}
async function assertSelectionEncompassesNodes(
page: Page,
comfyPage: ComfyPage,
nodeIds: string[]
) {
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, nodeIds)
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
for (const nodeId of nodeIds) {
const vis = result.nodeVisualBounds[nodeId]
expect(vis).toBeDefined()
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
}
}
test.describe(
'Selection bounding box (Vue mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const vueCases: ReadonlyArray<{
type: NodeType
state: NodeState
pos: Position
}> = [
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
]
for (const { type, state, pos } of vueCases) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: LAYOUTS[pos].ref,
[targetId]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
)
test.describe(
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('collapsed node narrows bounding box when bypass is removed', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
test('collapsed node widens bounding box when bypass is added', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
)
test.describe(
'Selection bounding box (legacy mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
{ state: 'expanded', pos: 'bottom-left' },
{ state: 'expanded', pos: 'bottom-right' },
{ state: 'collapsed', pos: 'bottom-left' },
{ state: 'collapsed', pos: 'bottom-right' }
]
for (const { state, pos } of legacyCases) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
[REGULAR_ID]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
)

View File

@@ -1,81 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Topbar menu commands', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('New command creates a new workflow tab', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await topbar.triggerTopbarCommand(['New'])
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Note')
window.app!.graph!.add(node)
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('File > Save opens save dialog', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['File', 'Save'])
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
await expect(saveDialog).toBeVisible()
})
test('View > Bottom Panel toggles bottom panel', async ({ comfyPage }) => {
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.menu.topbar.triggerTopbarCommand(['View', 'Bottom Panel'])
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -26,8 +26,6 @@ An Entity Component System (ECS) separates **identity** (entities), **data** (co
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
For the full problem catalog with line-level code references, see [Entity System Structural Problems](../architecture/entity-problems.md). For a map of all current entity relationships, see [Entity Interactions](../architecture/entity-interactions.md).
## Decision
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
@@ -174,7 +172,7 @@ Systems are pure functions that query the World for entities with specific compo
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
System design is deferred to a future ADR. For detailed before/after walkthroughs of how lifecycle operations (node removal, link creation, subgraph nesting, etc.) transform under ECS, see [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md).
System design is deferred to a future ADR.
### Migration Strategy
@@ -184,8 +182,6 @@ System design is deferred to a future ADR. For detailed before/after walkthrough
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
### Relationship to ADR 0003 (Command Pattern / CRDT)
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
@@ -235,23 +231,6 @@ Planned mitigations for the ECS render path:
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
## Supporting Documents
Companion architecture documents that expand on the design in this ADR:
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
## Notes
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.

View File

@@ -31,6 +31,9 @@ const config: KnipConfig = {
},
'apps/website': {
entry: ['src/scripts/**/*.ts']
},
'tools/test-recorder': {
project: ['src/**/*.ts']
}
},
ignoreBinaries: ['python3'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.7",
"version": "1.44.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -43,6 +43,7 @@
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"comfy-test": "tsx tools/test-recorder/src/index.ts",
"test:browser": "pnpm exec nx e2e",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",

View File

@@ -28,7 +28,6 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -45,6 +44,11 @@ export type {
CheckHubUsernameErrors,
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClaimInviteCodeData,
ClaimInviteCodeError,
ClaimInviteCodeErrors,
ClaimInviteCodeResponse,
ClaimInviteCodeResponses,
ClientOptions,
CreateAssetDownloadData,
CreateAssetDownloadError,
@@ -109,13 +113,6 @@ export type {
CreateWorkflowVersionRequest,
CreateWorkflowVersionResponse,
CreateWorkflowVersionResponses,
CreateWorkspaceApiKeyData,
CreateWorkspaceApiKeyError,
CreateWorkspaceApiKeyErrors,
CreateWorkspaceApiKeyRequest,
CreateWorkspaceApiKeyResponse,
CreateWorkspaceApiKeyResponse2,
CreateWorkspaceApiKeyResponses,
CreateWorkspaceData,
CreateWorkspaceError,
CreateWorkspaceErrors,
@@ -240,16 +237,12 @@ export type {
GetBillingStatusErrors,
GetBillingStatusResponse,
GetBillingStatusResponses,
GetCustomNodeProxyData,
GetCustomNodeProxyErrors,
GetCustomNodeProxyResponses,
GetDeletionRequestData,
GetDeletionRequestError,
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
GetFeaturesData,
GetFeaturesResponse,
@@ -270,9 +263,7 @@ export type {
GetGlobalSubgraphsResponse,
GetGlobalSubgraphsResponses,
GetHealthData,
GetHealthError,
GetHealthErrors,
GetHealthResponse,
GetHealthResponses,
GetHistoryData,
GetHistoryError,
@@ -294,6 +285,11 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetInviteCodeStatusData,
GetInviteCodeStatusError,
GetInviteCodeStatusErrors,
GetInviteCodeStatusResponse,
GetInviteCodeStatusResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -343,19 +339,9 @@ export type {
GetMyHubProfileErrors,
GetMyHubProfileResponse,
GetMyHubProfileResponses,
GetNodeByIdData,
GetNodeByIdErrors,
GetNodeByIdResponses,
GetNodeInfoData,
GetNodeInfoResponse,
GetNodeInfoResponses,
GetNodeInfoSchemaData,
GetNodeInfoSchemaResponses,
GetNodeReplacementsData,
GetNodeReplacementsError,
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetPaymentPortalData,
@@ -436,15 +422,11 @@ export type {
GetUserErrors,
GetUserResponse,
GetUserResponses,
GetUsersInfoData,
GetUsersInfoError,
GetUsersInfoErrors,
GetUsersInfoResponse,
GetUsersInfoResponses,
GetUsersRawData,
GetUsersRawErrors,
GetUsersRawResponses,
GetVhsQueryVideoData,
GetVhsQueryVideoError,
GetVhsQueryVideoErrors,
GetVhsQueryVideoResponse,
GetVhsQueryVideoResponses,
GetVhsViewAudioData,
GetVhsViewAudioErrors,
@@ -505,6 +487,8 @@ export type {
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
InviteCodeClaimResponse,
InviteCodeStatusResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
@@ -567,12 +551,6 @@ export type {
ListWorkflowsErrors,
ListWorkflowsResponse,
ListWorkflowsResponses,
ListWorkspaceApiKeysData,
ListWorkspaceApiKeysError,
ListWorkspaceApiKeysErrors,
ListWorkspaceApiKeysResponse,
ListWorkspaceApiKeysResponse2,
ListWorkspaceApiKeysResponses,
ListWorkspaceInvitesData,
ListWorkspaceInvitesError,
ListWorkspaceInvitesErrors,
@@ -623,9 +601,6 @@ export type {
PostAssetsFromWorkflowErrors,
PostAssetsFromWorkflowResponse,
PostAssetsFromWorkflowResponses,
PostCustomNodeProxyData,
PostCustomNodeProxyErrors,
PostCustomNodeProxyResponses,
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
@@ -686,11 +661,6 @@ export type {
ResubscribeResponse,
ResubscribeResponse2,
ResubscribeResponses,
RevokeWorkspaceApiKeyData,
RevokeWorkspaceApiKeyError,
RevokeWorkspaceApiKeyErrors,
RevokeWorkspaceApiKeyResponse,
RevokeWorkspaceApiKeyResponses,
RevokeWorkspaceInviteData,
RevokeWorkspaceInviteError,
RevokeWorkspaceInviteErrors,
@@ -698,6 +668,13 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SendUserInviteEmailData,
SendUserInviteEmailError,
SendUserInviteEmailErrors,
SendUserInviteEmailRequest,
SendUserInviteEmailResponse,
SendUserInviteEmailResponse2,
SendUserInviteEmailResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -741,12 +718,6 @@ export type {
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateHubWorkflowData,
UpdateHubWorkflowError,
UpdateHubWorkflowErrors,
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -763,11 +734,6 @@ export type {
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
UpdateSubscriptionCacheResponse,
UpdateSubscriptionCacheResponses,
UpdateWorkflowData,
UpdateWorkflowError,
UpdateWorkflowErrors,
@@ -799,13 +765,6 @@ export type {
UserResponse,
ValidationError,
ValidationResult,
VerifyApiKeyRequest,
VerifyApiKeyResponse,
VerifyWorkspaceApiKeyData,
VerifyWorkspaceApiKeyError,
VerifyWorkspaceApiKeyErrors,
VerifyWorkspaceApiKeyResponse,
VerifyWorkspaceApiKeyResponses,
ViewFileData,
ViewFileError,
ViewFileErrors,
@@ -820,7 +779,6 @@ export type {
WorkflowVersionContentResponse,
WorkflowVersionResponse,
Workspace,
WorkspaceApiKeyInfo,
WorkspaceSummary,
WorkspaceWithRole
} from './types.gen'

File diff suppressed because it is too large Load Diff

View File

@@ -20,32 +20,6 @@ export const zHubAssetUploadUrlRequest = z.object({
content_type: z.string()
})
/**
* Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token:
*
* * field omitted or null — leave unchanged
* * string field = "" — clear (for clearable string fields)
* * array field = [] — clear the list
* * any other value — set to the provided value
*
* Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value".
* Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint.
*
*/
export const zUpdateHubWorkflowRequest = z.object({
name: z.string().min(1).nullish(),
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
models: z.array(z.string()).nullish(),
custom_nodes: z.array(z.string()).nullish(),
tutorial_url: z.string().nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_token: z.string().nullish(),
thumbnail_comparison_token: z.string().nullish(),
sample_image_tokens_or_urls: z.array(z.string()).nullish(),
metadata: z.record(z.unknown()).nullish()
})
export const zPublishHubWorkflowRequest = z.object({
username: z.string(),
name: z.string(),
@@ -160,43 +134,8 @@ export const zHubWorkflowTemplateEntry = z.object({
thumbnailVariant: z.string().optional(),
mediaType: z.string().optional(),
mediaSubtype: z.string().optional(),
size: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
vram: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
usage: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
searchRank: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
isEssential: z.boolean().optional(),
size: z.number().optional(),
vram: z.number().optional(),
openSource: z.boolean().optional(),
profile: zHubProfileSummary.optional(),
tutorialUrl: z.string().optional(),
@@ -702,53 +641,6 @@ export const zJwksResponse = z.object({
keys: z.array(zJwkKey)
})
export const zVerifyApiKeyResponse = z.object({
user_id: z.string(),
email: z.string(),
name: z.string(),
is_admin: z.boolean(),
workspace_id: z.string(),
workspace_type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member']),
has_funds: z.boolean(),
is_active: z.boolean(),
permissions: z.array(z.string())
})
export const zVerifyApiKeyRequest = z.object({
api_key: z.string()
})
export const zWorkspaceApiKeyInfo = z.object({
id: z.string().uuid(),
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
revoked_at: z.string().datetime().optional(),
created_at: z.string().datetime()
})
export const zListWorkspaceApiKeysResponse = z.object({
api_keys: z.array(zWorkspaceApiKeyInfo)
})
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
created_at: z.string().datetime()
})
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
expires_at: z.string().datetime().optional()
})
export const zAcceptInviteResponse = z.object({
workspace_id: z.string(),
workspace_name: z.string()
@@ -1087,6 +979,22 @@ export const zAssetCreated = zAsset.and(
})
)
/**
* Response after sending an invite email
*/
export const zSendUserInviteEmailResponse = z.object({
success: z.boolean(),
message: z.string()
})
/**
* Request to send an invite email to a user
*/
export const zSendUserInviteEmailRequest = z.object({
email: z.string(),
force: z.boolean().optional().default(false)
})
export const zSetReviewStatusResponse = z.object({
share_ids: z.array(z.string()),
status: z.enum(['approved', 'rejected'])
@@ -1097,6 +1005,22 @@ export const zSetReviewStatusRequest = z.object({
status: z.enum(['approved', 'rejected'])
})
/**
* Response after successfully claiming an invite code
*/
export const zInviteCodeClaimResponse = z.object({
success: z.boolean(),
message: z.string()
})
/**
* Invite code status response
*/
export const zInviteCodeStatusResponse = z.object({
claimed: z.boolean(),
expired: z.boolean()
})
/**
* Response after deleting a session cookie
*/
@@ -1116,7 +1040,6 @@ export const zCreateSessionResponse = z.object({
* User information response
*/
export const zUserResponse = z.object({
id: z.string(),
status: z.string()
})
@@ -1271,16 +1194,8 @@ export const zQueueManageRequest = z.object({
* Queue information with pending and running jobs
*/
export const zQueueInfo = z.object({
queue_running: z
.array(
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
)
.optional(),
queue_pending: z
.array(
z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()])
)
.optional()
queue_running: z.array(z.array(z.unknown())).optional(),
queue_pending: z.array(z.array(z.unknown())).optional()
})
/**
@@ -1400,10 +1315,6 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
export const zBindingErrorResponse = z.object({
message: z.string()
})
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
@@ -1516,17 +1427,6 @@ export const zGetFeaturesResponse = z.object({
max_upload_size: z.number().int().optional()
})
export const zGetNodeReplacementsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - Node replacement mappings
*/
export const zGetNodeReplacementsResponse = z.record(z.unknown())
export const zGetWorkflowTemplatesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1688,7 +1588,7 @@ export const zViewFileData = z.object({
})
/**
* Processed PNG image with extracted channel
* Success - File content returned (used when channel or res parameter is present)
*/
export const zViewFileResponse = z.string()
@@ -2529,56 +2429,6 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* List of API keys
*/
export const zListWorkspaceApiKeysResponse2 = zListWorkspaceApiKeysResponse
export const zCreateWorkspaceApiKeyData = z.object({
body: zCreateWorkspaceApiKeyRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* API key created (plaintext returned once)
*/
export const zCreateWorkspaceApiKeyResponse2 = zCreateWorkspaceApiKeyResponse
export const zRevokeWorkspaceApiKeyData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* API key revoked
*/
export const zRevokeWorkspaceApiKeyResponse = z.void()
export const zVerifyWorkspaceApiKeyData = z.object({
body: zVerifyApiKeyRequest,
path: z.never().optional(),
query: z
.object({
include_billing: z.boolean().optional().default(false)
})
.optional()
})
/**
* Key is valid
*/
export const zVerifyWorkspaceApiKeyResponse = zVerifyApiKeyResponse
export const zGetUserData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2590,6 +2440,43 @@ export const zGetUserData = z.object({
*/
export const zGetUserResponse = zUserResponse
export const zGetInviteCodeStatusData = z.object({
body: z.never().optional(),
path: z.object({
code: z.string()
}),
query: z.never().optional()
})
/**
* Success - invite code exists
*/
export const zGetInviteCodeStatusResponse = zInviteCodeStatusResponse
export const zClaimInviteCodeData = z.object({
body: z.never().optional(),
path: z.object({
code: z.string()
}),
query: z.never().optional()
})
/**
* Success - invite code claimed successfully
*/
export const zClaimInviteCodeResponse = zInviteCodeClaimResponse
export const zSendUserInviteEmailData = z.object({
body: zSendUserInviteEmailRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - invite email sent successfully
*/
export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse
export const zSetReviewStatusData = z.object({
body: zSetReviewStatusRequest,
path: z.never().optional(),
@@ -2601,19 +2488,6 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Updated hub workflow detail
*/
export const zUpdateHubWorkflowResponse = zHubWorkflowDetail
export const zGetDeletionRequestData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2653,23 +2527,6 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
is_active: z.boolean(),
tier: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Cache updated successfully
*/
export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zGetJobStatusData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3134,25 +2991,6 @@ export const zGetExtensionsData = z.object({
query: z.never().optional()
})
/**
* URL paths (relative to web root) of available extension JS files
*/
export const zGetExtensionsResponse = z.array(z.string())
export const zGetNodeInfoSchemaData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetNodeByIdData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetVhsViewVideoData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3181,32 +3019,12 @@ export const zGetVhsQueryVideoData = z.object({
})
})
/**
* Video metadata
*/
export const zGetVhsQueryVideoResponse = z.object({
source: z.object({
size: z.tuple([z.number().int(), z.number().int()]),
fps: z.number(),
frames: z.number().int(),
duration: z.number()
})
})
export const zGetUsersInfoData = z.object({
export const zGetUsersRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Userdata storage information
*/
export const zGetUsersInfoResponse = z.object({
storage: z.string(),
migrated: z.boolean()
})
export const zGetApiViewVideoAliasData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3247,11 +3065,6 @@ export const zGetHealthData = z.object({
query: z.never().optional()
})
/**
* Service is healthy
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3321,19 +3134,3 @@ export const zGetStaticExtensionsData = z.object({
}),
query: z.never().optional()
})
export const zGetCustomNodeProxyData = z.object({
body: z.never().optional(),
path: z.object({
path: z.string()
}),
query: z.never().optional()
})
export const zPostCustomNodeProxyData = z.object({
body: z.never().optional(),
path: z.object({
path: z.string()
}),
query: z.never().optional()
})

View File

@@ -15951,35 +15951,40 @@ export interface components {
QuiverTextToSVGRequest: {
/**
* @description Model identifier for SVG generation
* @example arrow-1.1
* @default arrow-preview
*/
model: string;
/** @description Text description of the desired SVG output */
prompt: string;
/** @description Additional style or formatting guidance */
instructions?: string;
/** @description Optional reference images to guide style/composition. Accepts URL object, base64 object, or URL string shorthand. Runtime limits are model-specific. */
references?: (components["schemas"]["QuiverImageObject"] | string)[];
/** @description Up to 4 reference images (URL or base64) */
references?: components["schemas"]["QuiverImageObject"][];
/**
* @description Number of SVGs to generate
* @default 1
*/
n: number;
/**
* @description Sampling temperature
* @description Enable Server-Sent Events streaming
* @default false
*/
stream: boolean;
/**
* @description Randomness control
* @default 1
*/
temperature: number;
/**
* @description Nucleus sampling probability
* @description Nucleus sampling parameter
* @default 1
*/
top_p: number;
/**
* @description Penalty for tokens already present in prior output
* @description Token presence penalty
* @default 0
*/
presence_penalty: number | null;
presence_penalty: number;
/** @description Maximum number of output tokens */
max_output_tokens?: number;
};
@@ -15987,37 +15992,42 @@ export interface components {
QuiverImageToSVGRequest: {
/**
* @description Model identifier for SVG vectorization
* @example arrow-1.1
* @default arrow-preview
*/
model: string;
image: components["schemas"]["QuiverImageObject"];
/**
* @description Auto-crop image to the dominant subject before vectorization
* @description Automatically crop to dominant subject
* @default false
*/
auto_crop: boolean;
/** @description Square resize target in pixels */
target_size?: number;
/**
* @description Number of SVGs to generate
* @default 1
*/
n: number;
/**
* @description Enable Server-Sent Events streaming
* @default false
*/
stream: boolean;
/**
* @description Sampling temperature
* @description Randomness control
* @default 1
*/
temperature: number;
/**
* @description Nucleus sampling probability
* @description Nucleus sampling parameter
* @default 1
*/
top_p: number;
/**
* @description Penalty for tokens already present in prior output
* @description Token presence penalty
* @default 0
*/
presence_penalty: number | null;
presence_penalty: number;
/** @description Maximum number of output tokens */
max_output_tokens?: number;
};
@@ -16034,39 +16044,24 @@ export interface components {
/** @description Response from Quiver AI SVG generation/vectorization */
QuiverSVGResponse: {
/** @description Unique identifier for the generation */
id: string;
id?: string;
/** @description Unix timestamp of creation */
created: number;
data: {
/** @description Raw SVG markup */
svg: string;
created?: number;
data?: {
/** @description Generated SVG content */
svg?: string;
/**
* @description MIME type of the output
* @enum {string}
* @default image/svg+xml
*/
mime_type: "image/svg+xml";
mime_type: string;
}[];
/** @description Credit cost for this request. Use this for billing instead of usage tokens. */
credits?: number;
/**
* @deprecated
* @description Deprecated. Use credits for billing values.
*/
usage?: {
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
/** @description Total tokens used */
total_tokens?: number;
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
/** @description Input tokens used */
input_tokens?: number;
/**
* @deprecated
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
*/
/** @description Output tokens used */
output_tokens?: number;
};
};

View File

@@ -7,7 +7,6 @@ import {
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
truncateFilename
} from './formatUtil'
@@ -358,12 +357,4 @@ describe('formatUtil', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})
describe('isCivitaiModelUrl', () => {
it('recognizes civitai.red model URLs', () => {
expect(
isCivitaiModelUrl('https://civitai.red/api/download/models/123456')
).toBe(true)
})
})
})

View File

@@ -361,17 +361,9 @@ export const generateUUID = (): string => {
*/
export const isCivitaiModelUrl = (url: string): boolean => {
if (!isValidUrl(url)) return false
if (!url.includes('civitai.com')) return false
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
const isCivitaiHost =
hostname === 'civitai.com' ||
hostname.endsWith('.civitai.com') ||
hostname === 'civitai.red' ||
hostname.endsWith('.civitai.red')
if (!isCivitaiHost) {
return false
}
const pathname = urlObj.pathname
return (

97
pnpm-lock.yaml generated
View File

@@ -251,7 +251,7 @@ catalogs:
version: 16.5.0
gsap:
specifier: ^3.14.2
version: 3.14.2
version: 3.15.0
happy-dom:
specifier: ^20.0.11
version: 20.0.11
@@ -272,7 +272,7 @@ catalogs:
version: 6.3.1
lenis:
specifier: ^1.3.21
version: 1.3.21
version: 1.3.23
lint-staged:
specifier: ^16.2.7
version: 16.4.0
@@ -284,7 +284,7 @@ catalogs:
version: 2.71.0
monocart-coverage-reports:
specifier: ^2.12.9
version: 2.12.9
version: 2.12.10
nx:
specifier: 22.6.1
version: 22.6.1
@@ -770,7 +770,7 @@ importers:
version: 2.71.0
monocart-coverage-reports:
specifier: 'catalog:'
version: 2.12.9
version: 2.12.10
nx:
specifier: 'catalog:'
version: 22.6.1
@@ -936,10 +936,10 @@ importers:
version: 2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
gsap:
specifier: 'catalog:'
version: 3.14.2
version: 3.15.0
lenis:
specifier: 'catalog:'
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
version: 1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -1029,6 +1029,22 @@ importers:
specifier: 'catalog:'
version: 5.9.3
tools/test-recorder:
dependencies:
'@clack/prompts':
specifier: ^0.9.1
version: 0.9.1
picocolors:
specifier: 'catalog:'
version: 1.1.1
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.10.4
typescript:
specifier: 'catalog:'
version: 5.9.3
packages:
'@acemir/cssom@0.9.30':
@@ -1723,6 +1739,12 @@ packages:
resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
engines: {node: '>=18'}
'@clack/core@0.4.1':
resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==}
'@clack/prompts@0.9.1':
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
'@comfyorg/comfyui-electron-types@0.6.2':
resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==}
@@ -5669,8 +5691,8 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
console-grid@2.2.3:
resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==}
console-grid@2.2.4:
resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==}
constantinople@4.0.1:
resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
@@ -6521,6 +6543,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
foreground-child@4.0.3:
resolution: {integrity: sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==}
engines: {node: '>=16'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
@@ -6698,8 +6724,8 @@ packages:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
gsap@3.14.2:
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
h3@1.15.10:
resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==}
@@ -7312,8 +7338,8 @@ packages:
resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==}
engines: {node: '>=18'}
lenis@1.3.21:
resolution: {integrity: sha512-RXWTYm7KQE4Kv8ezxL6wvK0Oiv7aRr6FDo+eNaaniTeu7pLdHokqMIJ5CXO4x5ezvd+9ONdpSFkprLpXsVWmEw==}
lenis@1.3.23:
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
@@ -7584,8 +7610,8 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
lz-utils@2.1.0:
resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==}
lz-utils@2.1.1:
resolution: {integrity: sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
@@ -7863,12 +7889,12 @@ packages:
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
engines: {node: '>=18.0.0'}
monocart-coverage-reports@2.12.9:
resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==}
monocart-coverage-reports@2.12.10:
resolution: {integrity: sha512-veL2la1QlqS4aPn1m5X+AjPtyK5SP72p/mypf7qRC2Yy0E/ba3gzOFYFMAcAp5Y5pgmSlkTa2SCfrOgUqPmc7g==}
hasBin: true
monocart-locator@1.0.2:
resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==}
monocart-locator@1.0.3:
resolution: {integrity: sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
@@ -11188,6 +11214,17 @@ snapshots:
dependencies:
fontkitten: 1.0.3
'@clack/core@0.4.1':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@0.9.1':
dependencies:
'@clack/core': 0.4.1
picocolors: 1.1.1
sisteransi: 1.0.5
'@comfyorg/comfyui-electron-types@0.6.2': {}
'@csstools/color-helpers@5.1.0': {}
@@ -15256,7 +15293,7 @@ snapshots:
consola@3.4.2: {}
console-grid@2.2.3: {}
console-grid@2.2.4: {}
constantinople@4.0.1:
dependencies:
@@ -16298,6 +16335,10 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
foreground-child@4.0.3:
dependencies:
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.5:
@@ -16497,7 +16538,7 @@ snapshots:
section-matter: 1.0.0
strip-bom-string: 1.0.0
gsap@3.14.2: {}
gsap@3.15.0: {}
h3@1.15.10:
dependencies:
@@ -17188,7 +17229,7 @@ snapshots:
dependencies:
package-json: 10.0.1
lenis@1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
lenis@1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
optionalDependencies:
react: 19.2.4
vue: 3.5.13(typescript@5.9.3)
@@ -17397,7 +17438,7 @@ snapshots:
lz-string@1.5.0: {}
lz-utils@2.1.0: {}
lz-utils@2.1.1: {}
magic-string-ast@1.0.3:
dependencies:
@@ -17871,22 +17912,22 @@ snapshots:
modern-tar@0.7.3: {}
monocart-coverage-reports@2.12.9:
monocart-coverage-reports@2.12.10:
dependencies:
acorn: 8.16.0
acorn-loose: 8.5.2
acorn-walk: 8.3.5
commander: 14.0.3
console-grid: 2.2.3
console-grid: 2.2.4
eight-colors: 1.3.3
foreground-child: 3.3.1
foreground-child: 4.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
lz-utils: 2.1.0
monocart-locator: 1.0.2
lz-utils: 2.1.1
monocart-locator: 1.0.3
monocart-locator@1.0.2: {}
monocart-locator@1.0.3: {}
mrmime@2.0.1: {}

View File

@@ -1,6 +1,7 @@
packages:
- apps/**
- packages/**
- tools/**
catalog:
'@alloc/quick-lru': ^5.2.0

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const agentsDir = join(process.cwd(), '.claude', 'agents')
const patches = {
'playwright-test-planner.md': `
## ComfyUI Project Context
### Application Overview
ComfyUI is a **canvas-based node graph editor** for AI image generation. It is a complex SPA with:
- A **LiteGraph canvas** where users create workflows by connecting nodes
- A **Vue 3 sidebar** with node library, workflows panel, and settings
- A **topbar** with queue/run buttons and workspace controls
- A **search box** for finding and adding nodes (opens on double-click)
- WebSocket-based real-time communication with a Python backend
### Exploration Tips
- Start by loading a workflow: the app is most useful with nodes on the canvas
- Key UI areas to explore: canvas interactions, sidebar panels, topbar buttons, search box, context menus, settings dialog
- Double-click the canvas to open the node search box
- Right-click nodes/canvas for context menus
- The bottom panel shows job queue and execution logs
### Test Environment
- The seed test uses \`comfyPageFixture\` which provides a \`comfyPage\` object with extensive helpers
- Workflows (JSON files) are loaded via \`comfyPage.workflow.loadWorkflow('name')\`
- Available workflow assets are in \`browser_tests/assets/\`
- The backend MUST be running with \`--multi-user\` flag
- A Vite dev server runs on \`:5173\`
### When Creating Test Plans
- Reference specific workflow assets when a scenario needs a starting state
- Note that canvas interactions use pixel coordinates — these may vary across environments
- Distinguish between "canvas tests" (LiteGraph) and "UI tests" (Vue components)
- Include tags in your plans: \`@canvas\`, \`@widget\`, \`@sidebar\`, \`@smoke\`, \`@screenshot\`
- Reference \`browser_tests/fixtures/ComfyPage.ts\` for available test helpers`,
'playwright-test-generator.md': `
## ComfyUI Project Context
### Required Import Pattern
Generated tests MUST use ComfyUI fixtures, not generic \`@playwright/test\`:
\`\`\`typescript
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
\`\`\`
### Fixture Object
Tests receive \`comfyPage\` (not \`page\`) as their fixture:
\`\`\`typescript
test('my test', async ({ comfyPage }) => {
// Access raw page via comfyPage.page if needed
})
\`\`\`
### Key APIs
| Need | Use | Notes |
|------|-----|-------|
| Canvas element | \`comfyPage.canvas\` | Pre-configured Locator |
| Wait for render | \`comfyPage.nextFrame()\` | After canvas mutations |
| Load workflow | \`comfyPage.workflow.loadWorkflow('name')\` | Assets in \`browser_tests/assets/\` |
| Get node by type | \`comfyPage.nodeOps.getNodeRefsByType('KSampler')\` | Returns NodeReference[] |
| Search box | \`comfyPage.searchBox.fillAndSelectFirstNode('name')\` | Opens on canvas dblclick |
| Settings | \`comfyPage.settings.setSetting(key, value)\` | Clean up in afterEach |
| Keyboard | \`comfyPage.keyboard.press('Delete')\` | Focus canvas first |
| Context menu | \`comfyPage.contextMenu\` | Right-click interactions |
### Mandatory Test Structure
Every generated test must:
1. Be wrapped in \`test.describe('Name', { tag: ['@canvas'] }, () => { ... })\`
2. Include \`test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })\`
3. Use descriptive test names (not "test" or "test1")
### Anti-Patterns — NEVER Use
- ❌ \`page.goto()\` — fixture handles navigation
- ❌ \`page.waitForTimeout()\` — use \`comfyPage.nextFrame()\` or retrying assertions
- ❌ \`import from '@playwright/test'\` — use \`from '../fixtures/ComfyPage'\`
- ❌ Bare \`page.\` references — use \`comfyPage.page.\` if you need raw page access
### Reference
Read the fixture code for full API surface:
- \`browser_tests/fixtures/ComfyPage.ts\` — main fixture
- \`browser_tests/fixtures/helpers/\` — helper classes
- \`browser_tests/fixtures/components/\` — page object components
- See also: \`.claude/skills/codegen-transform/SKILL.md\` for transform rules`,
'playwright-test-healer.md': `
## ComfyUI Project Context
### Custom Fixtures
Tests in this project use \`comfyPage\` fixture, not bare \`page\`. When healing:
- Replace any \`page.\` references with \`comfyPage.page.\` if adding new code
- Use \`comfyPage.nextFrame()\` instead of adding \`waitForTimeout()\`
- Use fixture helpers (\`comfyPage.nodeOps\`, \`comfyPage.canvas\`, etc.) over raw locators
### Common Failure Causes in ComfyUI Tests
1. **Missing \`nextFrame()\`**: Canvas operations need \`await comfyPage.nextFrame()\` after mutations. This is the #1 cause of "works locally, fails in CI" issues.
2. **Canvas focus required**: Keyboard shortcuts won't work unless \`await comfyPage.canvas.click()\` is called first.
3. **Node position drift**: Pixel coordinates can shift between environments. When possible, replace with node references:
\`\`\`typescript
// Instead of: canvas.click({ position: { x: 423, y: 267 } })
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await node.click('title')
\`\`\`
4. **Settings pollution**: Settings persist across tests on the backend. Always reset changed settings in \`afterEach\`.
5. **Drag animation timing**: Use \`{ steps: 10 }\` option for drag operations, not \`{ steps: 1 }\`.
### Healing Safety Rules
- ❌ NEVER add \`waitForTimeout()\` — always use retrying assertions or \`nextFrame()\`
- ❌ NEVER "fix" a test by weakening assertions (e.g., removing an assertion that fails)
- ❌ NEVER modify the application code — only modify test code
- ⚠️ If a test fails because expected UI elements are missing, the app may have a regression — mark as \`test.fixme()\` with explanation, don't "heal" the assertion away
- ⚠️ If a test fails only in CI but passes locally, likely missing \`nextFrame()\` — don't mask with timeouts
### Reference
- \`browser_tests/fixtures/ComfyPage.ts\` — full fixture API
- \`browser_tests/fixtures/helpers/\` — available helper classes
- \`.claude/skills/writing-playwright-tests/SKILL.md\` — testing conventions
- \`.claude/skills/codegen-transform/SKILL.md\` — transform rules`
}
const CONTEXT_HEADING = '## ComfyUI Project Context'
const missingFiles = []
for (const [filename, patch] of Object.entries(patches)) {
const filePath = join(agentsDir, filename)
if (!existsSync(filePath)) {
missingFiles.push(filename)
console.error(`${filename}: not found (run init-agents first)`)
continue
}
let content = readFileSync(filePath, 'utf-8')
// Strip existing ComfyUI context section (heading to EOF)
const idx = content.indexOf(CONTEXT_HEADING)
if (idx !== -1) {
// Trim trailing whitespace before the heading too
content = content.substring(0, idx).trimEnd()
console.log(` ♻️ ${filename}: stripped existing ComfyUI context`)
}
content += patch
writeFileSync(filePath, content, 'utf-8')
console.log(`${filename}: patched`)
}
if (missingFiles.length > 0) {
throw new Error(
`Missing expected Playwright agent files: ${missingFiles.join(', ')}`
)
}

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Navigate to repo root (script location relative)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "🎭 Updating Playwright agent definitions..."
echo ""
# Step 1: Regenerate base agent files
echo "Step 1: Running init-agents..."
cd "$REPO_ROOT"
pnpm exec playwright init-agents --loop=claude
echo ""
echo "Step 2: Applying ComfyUI patches..."
node "$SCRIPT_DIR/patch-playwright-agents.js"
echo ""
echo "✅ Agent files updated and patched."
echo ""
echo "Files modified:"
echo " .claude/agents/playwright-test-planner.md"
echo " .claude/agents/playwright-test-generator.md"
echo " .claude/agents/playwright-test-healer.md"
echo ""
echo "Review changes with: git diff .claude/agents/"

View File

@@ -1,10 +0,0 @@
/** Padding applied around the selection bounding rect (both screen + canvas). */
export const SELECTION_BOUNDS_PADDING = 10
/** Rectangle expressed in canvas-world coordinates. */
export interface CanvasRect {
x: number
y: number
w: number
h: number
}

View File

@@ -1,22 +1,13 @@
<template>
<div
ref="rootEl"
data-testid="terminal-root"
class="relative size-full overflow-hidden bg-neutral-900"
>
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
<div class="p-terminal size-full rounded-none p-2">
<div
ref="terminalEl"
data-testid="terminal-host"
class="terminal-host h-full"
/>
<div ref="terminalEl" class="terminal-host h-full" />
</div>
<Button
v-tooltip.left="{
value: tooltipText,
showDelay: 300
}"
data-testid="terminal-copy-button"
variant="secondary"
size="sm"
:class="

View File

@@ -1,15 +1,10 @@
<template>
<div class="size-full bg-transparent">
<p
v-if="errorMessage"
data-testid="terminal-error-message"
class="p-4 text-center"
>
<p v-if="errorMessage" class="p-4 text-center">
{{ errorMessage }}
</p>
<ProgressSpinner
v-else-if="loading"
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />

View File

@@ -1,133 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { Bounds } from '@/renderer/core/layout/types'
import WidgetBoundingBox from './WidgetBoundingBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
}
}
})
const ScrubableNumberInputStub = defineComponent({
name: 'ScrubableNumberInput',
props: {
modelValue: { type: Number, default: 0 },
min: { type: Number, default: 0 },
step: { type: Number, default: 1 },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<input
type="number"
:value="modelValue"
:disabled="disabled"
:data-min="min"
:data-step="step"
@input="$emit('update:modelValue', Number(($event.target).value))"
/>
`
})
function renderBox(initial: Bounds, disabled = false) {
const value = ref<Bounds>(initial)
const Harness = defineComponent({
components: { WidgetBoundingBox },
setup: () => ({ value, disabled }),
template: '<WidgetBoundingBox v-model="value" :disabled="disabled" />'
})
const utils = render(Harness, {
global: {
plugins: [i18n],
stubs: { ScrubableNumberInput: ScrubableNumberInputStub }
}
})
return { ...utils, value }
}
describe('WidgetBoundingBox', () => {
describe('Label rendering', () => {
it('renders labels for x, y, width, and height', () => {
renderBox({ x: 0, y: 0, width: 100, height: 100 })
expect(screen.getByText('X')).toBeInTheDocument()
expect(screen.getByText('Y')).toBeInTheDocument()
expect(screen.getByText('Width')).toBeInTheDocument()
expect(screen.getByText('Height')).toBeInTheDocument()
})
})
describe('Initial values', () => {
it('displays the initial bounds across four inputs', () => {
renderBox({ x: 10, y: 20, width: 300, height: 400 })
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
expect(inputs.map((i) => i.value)).toEqual(['10', '20', '300', '400'])
})
})
describe('Constraints', () => {
it('sets min=0 for x/y and min=1 for width/height', () => {
renderBox({ x: 0, y: 0, width: 1, height: 1 })
const inputs = screen.getAllByRole('spinbutton')
expect(inputs[0].dataset.min).toBe('0') // x
expect(inputs[1].dataset.min).toBe('0') // y
expect(inputs[2].dataset.min).toBe('1') // width
expect(inputs[3].dataset.min).toBe('1') // height
})
})
describe('v-model updates', () => {
it('updates x immutably, preserving y/width/height', async () => {
const { value } = renderBox({ x: 10, y: 20, width: 100, height: 200 })
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
const user = userEvent.setup()
await user.clear(inputs[0])
await user.type(inputs[0], '55')
expect(value.value).toEqual({
x: 55,
y: 20,
width: 100,
height: 200
})
})
it('updates height immutably without mutating the original bounds', async () => {
const initial = { x: 10, y: 20, width: 100, height: 200 }
const { value } = renderBox(initial)
const inputs = screen.getAllByRole('spinbutton') as HTMLInputElement[]
const user = userEvent.setup()
await user.clear(inputs[3])
await user.type(inputs[3], '500')
expect(value.value.height).toBe(500)
expect(initial).toEqual({ x: 10, y: 20, width: 100, height: 200 })
expect(value.value).not.toBe(initial)
})
})
describe('Disabled state', () => {
it('disables all four inputs when disabled=true', () => {
renderBox({ x: 0, y: 0, width: 1, height: 1 }, true)
for (const input of screen.getAllByRole('spinbutton')) {
expect(input).toBeDisabled()
}
})
it('leaves all four inputs enabled when disabled=false', () => {
renderBox({ x: 0, y: 0, width: 1, height: 1 }, false)
for (const input of screen.getAllByRole('spinbutton')) {
expect(input).not.toBeDisabled()
}
})
})
})

View File

@@ -1,116 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import MultiSelectWidget from './MultiSelectWidget.vue'
const MultiSelectStub = defineComponent({
name: 'MultiSelect',
inheritAttrs: false,
props: {
modelValue: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
display: { type: String, default: '' }
},
template: `<div data-testid="multiselect"
:data-options="JSON.stringify(options)"
:data-placeholder="placeholder"
:data-display="display"
:data-model-value="JSON.stringify(modelValue)" />`
})
function makeWidget(
inputSpec: Partial<ComboInputSpec>
): ComponentWidget<string[]> {
return {
name: 'multi',
inputSpec: {
type: 'COMBO',
name: 'multi',
...inputSpec
} as ComboInputSpec
} as unknown as ComponentWidget<string[]>
}
function renderWidget(
inputSpec: Partial<ComboInputSpec>,
initialValue: string[] = []
) {
const value = ref<string[]>(initialValue)
const widget = makeWidget(inputSpec)
const Harness = defineComponent({
components: { MultiSelectWidget },
setup: () => ({ value, widget }),
template: '<MultiSelectWidget v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: { plugins: [PrimeVue], stubs: { MultiSelect: MultiSelectStub } }
})
return { ...utils, value }
}
describe('MultiSelectWidget', () => {
describe('Option list', () => {
it('passes inputSpec.options through as MultiSelect options', () => {
renderWidget({ options: ['a', 'b', 'c'] })
const el = screen.getByTestId('multiselect')
expect(JSON.parse(el.dataset.options!)).toEqual(['a', 'b', 'c'])
})
it('falls back to an empty list when inputSpec.options is absent', () => {
renderWidget({})
const el = screen.getByTestId('multiselect')
expect(JSON.parse(el.dataset.options!)).toEqual([])
})
})
describe('Placeholder', () => {
it('reads placeholder from multi_select.placeholder', () => {
renderWidget({
options: ['a'],
multi_select: { placeholder: 'Pick one or more' }
})
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
'Pick one or more'
)
})
it('defaults placeholder to "Select items" when not provided', () => {
renderWidget({ options: ['a'] })
expect(screen.getByTestId('multiselect').dataset.placeholder).toBe(
'Select items'
)
})
})
describe('Display mode', () => {
it('uses "chip" display when multi_select.chip is true', () => {
renderWidget({ options: ['a'], multi_select: { chip: true } })
expect(screen.getByTestId('multiselect').dataset.display).toBe('chip')
})
it('uses "comma" display when chip is false or missing', () => {
renderWidget({ options: ['a'], multi_select: { chip: false } })
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
})
it('uses "comma" display when multi_select is absent', () => {
renderWidget({ options: ['a'] })
expect(screen.getByTestId('multiselect').dataset.display).toBe('comma')
})
})
describe('Value binding', () => {
it('forwards the initial selected items to MultiSelect', () => {
renderWidget({ options: ['a', 'b'] }, ['a'])
const el = screen.getByTestId('multiselect')
expect(JSON.parse(el.dataset.modelValue!)).toEqual(['a'])
})
})
})

View File

@@ -1,206 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
const execHolder = vi.hoisted(() => ({
state: null as {
executingNodeIds: Array<string | number>
isIdle: boolean
} | null
}))
vi.mock('@/stores/executionStore', async () => {
const { reactive } = await import('vue')
execHolder.state = reactive({
executingNodeIds: [] as Array<string | number>,
isIdle: true
})
return {
useExecutionStore: () => execHolder.state
}
})
const execState = (): {
executingNodeIds: Array<string | number>
isIdle: boolean
} => execHolder.state!
import TextPreviewWidget from './TextPreviewWidget.vue'
const SkeletonStub = defineComponent({
name: 'Skeleton',
template: '<div data-testid="skeleton" />'
})
function renderPreview(
text: string,
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
) {
const value = ref(text)
const Harness = defineComponent({
components: { TextPreviewWidget },
setup: () => ({ value, nodeId }),
template: '<TextPreviewWidget v-model="value" :node-id="nodeId" />'
})
return render(Harness, {
global: {
plugins: [PrimeVue],
stubs: { Skeleton: SkeletonStub }
}
})
}
describe('TextPreviewWidget', () => {
beforeEach(() => {
execState().executingNodeIds = []
execState().isIdle = true
vi.clearAllMocks()
})
describe('Text formatting', () => {
it('renders plain text content', () => {
const { container } = renderPreview('hello world')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const span = container.querySelector('span')
expect(span?.innerHTML).toContain('hello world')
})
it('converts newlines to <br> tags', () => {
const { container } = renderPreview('line1\nline2')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const span = container.querySelector('span')
expect(span?.innerHTML).toContain('<br')
})
it('auto-links bare http URLs', () => {
const { container } = renderPreview('visit https://example.com for info')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('href')).toBe('https://example.com')
})
})
describe('Bracketed link tokens [[label|url]]', () => {
it('renders an http link with the supplied label', () => {
const { container } = renderPreview(
'see [[Docs|https://docs.example.com]]'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('href')).toBe('https://docs.example.com')
expect(anchor?.textContent).toBe('Docs')
})
it('sets target=_blank and rel=noopener for safety', () => {
const { container } = renderPreview('[[Docs|https://x.example.com]]')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor?.getAttribute('target')).toBe('_blank')
expect(anchor?.getAttribute('rel')).toContain('noopener')
})
it('renders label as plain text when url is not http(s)', () => {
const { container } = renderPreview('[[Local|javascript:alert(1)]]')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('a')).toBeNull()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('span')?.textContent).toContain('Local')
})
it('escapes HTML in the label to prevent XSS', () => {
const { container } = renderPreview(
'[[<img src=x>|https://x.example.com]]'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const span = container.querySelector('span')
expect(span?.innerHTML).toContain('&lt;img')
expect(span?.innerHTML).not.toContain('<img src')
})
})
describe('Raw HTML sanitisation in modelValue', () => {
it('drops img tags entirely (strict allowlist is <a> + <br> only)', () => {
const { container } = renderPreview('<img src=x onerror="alert(1)">')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toBeNull()
})
it('drops script tags from raw HTML in modelValue', () => {
const { container } = renderPreview(
'hello<script>window.__xss = true</script>world'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('script')).toBeNull()
})
it('drops iframe tags', () => {
const { container } = renderPreview(
'<iframe src="https://evil.example.com"></iframe>'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('iframe')).toBeNull()
})
it('strips inline javascript: hrefs on anchors', () => {
const { container } = renderPreview(
'<a href="javascript:alert(1)">click</a>'
)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const anchor = container.querySelector('a')
expect(anchor).not.toBeNull()
const href = anchor?.getAttribute('href')
expect(href == null || !href.startsWith('javascript:')).toBe(true)
})
it('preserves the <br> tag produced by nl2br', () => {
const { container } = renderPreview('line1\nline2')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('br')).toBeInTheDocument()
})
})
describe('Execution state', () => {
it('hides the Skeleton on mount when execution is already idle', () => {
execState().executingNodeIds = []
execState().isIdle = true
renderPreview('text', { nodeId: 'n1' })
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('shows a Skeleton on mount when the parent node is executing', () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
})
it('hides the Skeleton when execution transitions to idle', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
execState().executingNodeIds = []
execState().isIdle = true
await nextTick()
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
execState().executingNodeIds = ['other']
await nextTick()
expect(screen.queryByTestId('skeleton')).toBeNull()
})
})
})

View File

@@ -12,9 +12,8 @@
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionStore } from '@/stores/executionStore'
@@ -26,11 +25,7 @@ const props = defineProps<{
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = computed(() => {
if (executionStore.isIdle) return false
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
return executionStore.executingNodeIds.includes(parentNodeId)
})
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => {
const src = modelValue.value
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
@@ -56,27 +51,39 @@ const formattedText = computed(() => {
: safeLabel
})
// Strict allowlist: this widget only needs anchors and line breaks. Raw
// websocket progress text flows into modelValue, so we drop every other
// tag (img, script, iframe, etc.) to keep the v-html trust boundary tight.
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['a', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel']
})
return html
})
let parentNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId ?? parentNodeId
parentNodeId = props.nodeId
})
// Lazily adopt the first executing node as the parent when no nodeId is known.
watch(
() => executionStore.executingNodeIds,
(ids) => {
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
() => {
if (executionStore.isIdle) {
isParentNodeExecuting.value = false
stopWatching()
return
}
// Check if parent node is no longer in the executing nodes list
if (
parentNodeId &&
!executionStore.executingNodeIds.includes(parentNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
// Set parent node ID if not set yet
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
parentNodeId = executionStore.executingNodeIds[0]
}
}
)
</script>

View File

@@ -14,7 +14,6 @@
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
:data-testid="`help-menu-item-${menuItem.key}`"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@@ -104,7 +103,6 @@
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded-sm p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
:data-testid="`help-release-item-${release.version}`"
role="button"
tabindex="0"
@click="onReleaseClick(release)"

View File

@@ -4,7 +4,6 @@
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
data-testid="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
@@ -39,7 +38,6 @@
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
data-testid="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>

View File

@@ -1,246 +0,0 @@
/* eslint-disable vue/one-component-per-file */
/* eslint-disable vue/no-reserved-component-names */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { Ref } from 'vue'
const cropHolder = vi.hoisted(() => ({
state: null as Record<string, unknown> | null
}))
function createDefaultCropState() {
return {
imageUrl: ref<string | null>(null),
isLoading: ref(false),
selectedRatio: ref('1:1'),
isLockEnabled: ref(false),
cropBoxStyle: ref({}),
resizeHandles: ref([]),
handleImageLoad: () => {},
handleImageError: () => {},
handleDragStart: () => {},
handleDragMove: () => {},
handleDragEnd: () => {},
handleResizeStart: () => {},
handleResizeMove: () => {},
handleResizeEnd: () => {}
}
}
vi.mock('@/composables/useImageCrop', async () => {
return {
ASPECT_RATIOS: {
'1:1': 1,
'4:3': 4 / 3,
custom: null
},
useImageCrop: () => {
if (!cropHolder.state) {
cropHolder.state = createDefaultCropState()
}
return cropHolder.state
}
}
})
const upstreamHolder = vi.hoisted(() => ({
ref: null as Ref<unknown> | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
boundsExtractor: () => () => undefined
}
})
import WidgetImageCrop from './WidgetImageCrop.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
imageCrop: {
loading: 'Loading...',
noInputImage: 'No input image connected',
cropPreviewAlt: 'Crop preview',
ratio: 'Ratio',
lockRatio: 'Lock aspect ratio',
unlockRatio: 'Unlock aspect ratio',
custom: 'Custom'
},
boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' }
}
}
})
const ButtonStub = defineComponent({
name: 'Button',
inheritAttrs: false,
template: '<button v-bind="$attrs" type="button"><slot /></button>'
})
const Passthrough = defineComponent({
template: '<div><slot /></div>'
})
const WidgetBoundingBoxStub = defineComponent({
name: 'WidgetBoundingBox',
props: {
modelValue: { type: Object, default: () => ({}) },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `<div data-testid="bbox-child"
:data-disabled="String(disabled)"
:data-model="JSON.stringify(modelValue)"
@click="$emit('update:modelValue', { x: 1, y: 2, width: 3, height: 4 })"
/>`
})
function primeCropState(overrides: Record<string, unknown> = {}) {
cropHolder.state = {
...createDefaultCropState(),
...overrides
}
}
function makeWidget(
overrides: Partial<SimplifiedWidget<Bounds>> = {}
): SimplifiedWidget<Bounds> {
return {
name: 'crop',
type: 'imagecrop',
value: { x: 0, y: 0, width: 512, height: 512 },
options: {},
...overrides
} as SimplifiedWidget<Bounds>
}
function renderWidget(
widget: SimplifiedWidget<Bounds> = makeWidget(),
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
) {
const value = ref<Bounds>(initialModel)
const Harness = defineComponent({
components: { WidgetImageCrop },
setup: () => ({ value, widget }),
template:
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
})
const utils = render(Harness, {
global: {
plugins: [i18n],
stubs: {
Button: ButtonStub,
Select: Passthrough,
SelectContent: Passthrough,
SelectTrigger: Passthrough,
SelectValue: Passthrough,
SelectItem: Passthrough,
WidgetBoundingBox: WidgetBoundingBoxStub
}
}
})
return { ...utils, value }
}
describe('WidgetImageCrop', () => {
beforeEach(() => {
cropHolder.state = null
upstreamHolder.ref = null
})
describe('Image states', () => {
it('shows the empty-state placeholder when imageUrl is null', () => {
primeCropState()
renderWidget()
expect(screen.getByTestId('crop-empty-state')).toBeInTheDocument()
expect(screen.getByText('No input image connected')).toBeInTheDocument()
})
it('shows the loading message when isLoading is true', () => {
primeCropState({ isLoading: ref(true), imageUrl: ref('/img.png') })
renderWidget()
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(screen.queryByTestId('crop-empty-state')).toBeNull()
})
it('renders an img when imageUrl is set and not loading', () => {
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
renderWidget()
expect(
screen.getByRole('img', { name: 'Crop preview' })
).toBeInTheDocument()
expect(screen.queryByText('Loading...')).toBeNull()
})
it('renders the crop overlay when an image is loaded', () => {
primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) })
renderWidget()
expect(screen.getByTestId('crop-overlay')).toBeInTheDocument()
})
})
describe('Disabled state', () => {
it('hides the ratio controls when widget is disabled', () => {
renderWidget(makeWidget({ options: { disabled: true } }))
expect(screen.queryByText('Ratio')).toBeNull()
})
it('shows the ratio controls when widget is enabled', () => {
renderWidget()
expect(screen.getByText('Ratio')).toBeInTheDocument()
})
it('passes disabled=true to the bounding box child when disabled', () => {
renderWidget(makeWidget({ options: { disabled: true } }))
expect(screen.getByTestId('bbox-child').dataset.disabled).toBe('true')
})
})
describe('Bounds delegation', () => {
it('forwards v-model to the bounding box child', () => {
renderWidget(undefined, { x: 5, y: 10, width: 100, height: 200 })
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
expect(parsed).toEqual({ x: 5, y: 10, width: 100, height: 200 })
})
it('updates v-model when the bounding box emits a change', async () => {
const { value } = renderWidget()
const user = userEvent.setup()
await user.click(screen.getByTestId('bbox-child'))
expect(value.value).toEqual({ x: 1, y: 2, width: 3, height: 4 })
})
it('uses upstream bounds when disabled and upstream is available', () => {
upstreamHolder.ref = ref<unknown>({
x: 7,
y: 8,
width: 20,
height: 30
})
renderWidget(
makeWidget({
options: { disabled: true },
linkedUpstream: { nodeId: 'n1' }
}),
{ x: 0, y: 0, width: 512, height: 512 }
)
const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!)
expect(parsed).toEqual({ x: 7, y: 8, width: 20, height: 30 })
})
})
})

View File

@@ -1,185 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const upstreamHolder = vi.hoisted(() => ({
ref: null as { value: unknown } | null
}))
vi.mock('@/composables/useUpstreamValue', async () => {
const { ref } = await import('vue')
return {
useUpstreamValue: () => {
upstreamHolder.ref = upstreamHolder.ref ?? ref<unknown>(undefined)
return upstreamHolder.ref
},
singleValueExtractor: () => () => undefined
}
})
const outputsHolder = vi.hoisted(() => ({
nodeOutputs: {} as Record<string, unknown>
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => outputsHolder
}))
import WidgetRange from './WidgetRange.vue'
const RangeEditorStub = defineComponent({
name: 'RangeEditor',
props: {
modelValue: { type: Object, default: () => ({ min: 0, max: 1 }) },
disabled: { type: Boolean, default: false },
histogram: { type: Object, default: null },
display: { type: String, default: '' }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<div data-testid="range-editor"
:data-disabled="String(disabled)"
:data-has-histogram="String(!!histogram)"
:data-model="JSON.stringify(modelValue)"
:data-display="display"
@click="$emit('update:modelValue', { min: 5, max: 10 })"
/>
`
})
function makeWidget(
options: Partial<IWidgetRangeOptions> = {},
widgetOverrides: Partial<
SimplifiedWidget<RangeValue, IWidgetRangeOptions>
> = {}
): SimplifiedWidget<RangeValue, IWidgetRangeOptions> {
return {
name: 'range_w',
type: 'range',
value: { min: 0, max: 1 },
options: options as IWidgetRangeOptions,
...widgetOverrides
} as SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}
function setUpstream(value: RangeValue | undefined) {
if (!upstreamHolder.ref) upstreamHolder.ref = { value: undefined }
upstreamHolder.ref.value = value
}
function renderWidget(
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>,
initialModel: RangeValue = { min: 0, max: 1 }
) {
const value = ref<RangeValue>(initialModel)
const Harness = defineComponent({
components: { WidgetRange },
setup: () => ({ value, widget }),
template: '<WidgetRange v-model="value" :widget="widget" />'
})
const utils = render(Harness, {
global: { stubs: { RangeEditor: RangeEditorStub } }
})
return { ...utils, value }
}
describe('WidgetRange', () => {
beforeEach(() => {
upstreamHolder.ref = null
outputsHolder.nodeOutputs = {}
})
describe('Value pass-through', () => {
it('forwards modelValue to the RangeEditor', () => {
renderWidget(makeWidget(), { min: 0.2, max: 0.8 })
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.2, max: 0.8 })
})
it('propagates editor updates back to v-model', async () => {
const { value } = renderWidget(makeWidget())
const user = userEvent.setup()
await user.click(screen.getByTestId('range-editor'))
expect(value.value).toEqual({ min: 5, max: 10 })
})
it('forwards the display option to the RangeEditor', () => {
renderWidget(makeWidget({ display: 'histogram' }))
expect(screen.getByTestId('range-editor').dataset.display).toBe(
'histogram'
)
})
})
describe('Disabled state', () => {
it('passes disabled=true when widget.options.disabled is set', () => {
renderWidget(makeWidget({ disabled: true }))
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('true')
})
it('passes disabled=false by default', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('range-editor').dataset.disabled).toBe('false')
})
it('shows upstream value when disabled with a valid upstream', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(
makeWidget({ disabled: true } as IWidgetRangeOptions, {
linkedUpstream: { nodeId: 'n1' }
}),
{ min: 0, max: 1 }
)
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0.3, max: 0.7 })
})
it('ignores upstream value when not disabled', () => {
setUpstream({ min: 0.3, max: 0.7 })
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
min: 0,
max: 1
})
const el = screen.getByTestId('range-editor')
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
})
})
describe('Histogram', () => {
it('passes null histogram when nodeLocatorId is absent', () => {
renderWidget(makeWidget())
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)
})
it('passes a histogram when node output has a matching histogram entry', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
})
it('treats an empty histogram array as null', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)
})
})
})

View File

@@ -50,7 +50,7 @@ vi.mock('@/stores/commandStore', () => ({
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
}
}))
}))
@@ -284,7 +284,7 @@ describe('ErrorNodeCard.vue', () => {
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
'_blank',
'noopener,noreferrer'
)

View File

@@ -1,159 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useErrorActions } from './useErrorActions'
const mocks = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: vi.fn(),
execute: vi.fn(),
telemetry: null as {
trackUiButtonClicked: ReturnType<typeof vi.fn>
trackHelpResourceClicked: ReturnType<typeof vi.fn>
} | null,
staticUrls: {
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
}
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mocks.execute
})
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: mocks.staticUrls
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mocks.telemetry
}))
describe('useErrorActions', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mocks.telemetry = {
trackUiButtonClicked: mocks.trackUiButtonClicked,
trackHelpResourceClicked: mocks.trackHelpResourceClicked
}
mocks.trackUiButtonClicked.mockReset()
mocks.trackHelpResourceClicked.mockReset()
mocks.execute.mockReset()
windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
})
afterEach(() => {
windowOpenSpy.mockRestore()
})
describe('openGitHubIssues', () => {
it('tracks the button click and opens the GitHub issues URL in a new tab', () => {
const { openGitHubIssues } = useErrorActions()
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
'_blank',
'noopener,noreferrer'
)
})
it('still opens the link when telemetry is unavailable', () => {
mocks.telemetry = null
const { openGitHubIssues } = useErrorActions()
openGitHubIssues()
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
'_blank',
'noopener,noreferrer'
)
})
})
describe('contactSupport', () => {
it('tracks the help resource click and executes the contact support command', () => {
mocks.execute.mockReturnValue('executed')
const { contactSupport } = useErrorActions()
const result = contactSupport()
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(result).toBe('executed')
})
it('returns the execute promise when the command is async', async () => {
mocks.execute.mockResolvedValue('done')
const { contactSupport } = useErrorActions()
await expect(contactSupport()).resolves.toBe('done')
})
it('still executes the command when telemetry is unavailable', () => {
mocks.telemetry = null
const { contactSupport } = useErrorActions()
void contactSupport()
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})
describe('findOnGitHub', () => {
it('tracks the click and opens a URL-encoded issue search with " is:issue" appended', () => {
const { findOnGitHub } = useErrorActions()
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(
`${mocks.staticUrls.githubIssues}?q=${expectedQuery}`,
'_blank',
'noopener,noreferrer'
)
})
it('URL-encodes messages with special characters', () => {
const { findOnGitHub } = useErrorActions()
findOnGitHub('error with spaces & symbols?')
const [[url]] = windowOpenSpy.mock.calls as unknown as [[string]]
expect(url).toContain('?q=')
const queryPart = url.split('?q=')[1]
expect(decodeURIComponent(queryPart)).toBe(
'error with spaces & symbols? is:issue'
)
})
it('still opens the link when telemetry is unavailable', () => {
mocks.telemetry = null
const { findOnGitHub } = useErrorActions()
findOnGitHub('boom')
expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled()
expect(windowOpenSpy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,392 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
const mocks = vi.hoisted(() => {
// Helpers only — imports happen inside factories below.
return {
getLogs: vi.fn(),
serialize: vi.fn(),
refetchSystemStats: vi.fn(),
generateErrorReport: vi.fn()
}
})
const storeState = vi.hoisted(() => {
// Plain objects wired up in beforeEach. Tests use setStoreState to swap values.
return {
systemStats: null as unknown,
isLoading: false
}
})
vi.mock('@/scripts/api', () => ({
api: {
getLogs: mocks.getLogs
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: mocks.serialize
}
}
}))
vi.mock('@/utils/errorReportUtil', () => ({
generateErrorReport: mocks.generateErrorReport
}))
vi.mock('@/stores/systemStatsStore', async () => {
const { ref: vueRef } = await import('vue')
const systemStatsRef = vueRef<unknown>(null)
const isLoadingRef = vueRef(false)
return {
useSystemStatsStore: () => ({
get systemStats() {
return systemStatsRef.value
},
set systemStats(value: unknown) {
systemStatsRef.value = value
},
get isLoading() {
return isLoadingRef.value
},
set isLoading(value: boolean) {
isLoadingRef.value = value
},
refetchSystemStats: mocks.refetchSystemStats,
__setSystemStats(value: unknown) {
systemStatsRef.value = value
},
__setIsLoading(value: boolean) {
isLoadingRef.value = value
}
})
}
})
type TestStore = ReturnType<typeof useSystemStatsStore> & {
__setSystemStats: (value: unknown) => void
__setIsLoading: (value: boolean) => void
}
async function getStore(): Promise<TestStore> {
const mod = await import('@/stores/systemStatsStore')
return mod.useSystemStatsStore() as unknown as TestStore
}
const sampleSystemStats = {
system: {
os: 'Linux',
comfyui_version: '1.0.0',
argv: [],
python_version: '3.11',
embedded_python: false,
pytorch_version: '2.3.0'
},
devices: []
}
function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: '42',
errors: [],
...overrides
}
}
describe('useErrorReport', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
mocks.getLogs.mockReset()
mocks.serialize.mockReset()
mocks.refetchSystemStats.mockReset()
mocks.generateErrorReport.mockReset()
storeState.systemStats = null
storeState.isLoading = false
const store = await getStore()
store.__setSystemStats(null)
store.__setIsLoading(false)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns early without enrichment when the card has no runtime errors', async () => {
const card = makeCard({
errors: [{ message: 'static', details: 'details' }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'details' })
})
it('enriches each runtime error with a generated report when systemStats is present', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('server logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionType }: { exceptionType: string }) =>
`report:${exceptionType}`
)
const card = makeCard({
errors: [
{
message: 'CUDA oom',
details: 'trace-0',
isRuntimeError: true,
exceptionType: 'RuntimeError'
},
{
message: 'static',
details: 'skip-me'
},
{
message: 'Other runtime error',
details: 'trace-2',
isRuntimeError: true
}
]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(2)
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(1, {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',
workflow: { nodes: [] }
})
expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
exceptionType: 'Runtime Error',
exceptionMessage: 'Other runtime error',
traceback: 'trace-2'
})
)
expect(displayedDetailsMap.value).toEqual({
0: 'report:RuntimeError',
1: 'skip-me',
2: 'report:Runtime Error'
})
})
it('awaits the systemStats loading flag before proceeding', async () => {
const store = await getStore()
store.__setIsLoading(true)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockReturnValue('report')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
store.__setSystemStats(sampleSystemStats)
store.__setIsLoading(false)
await flushPromises()
expect(mocks.getLogs).toHaveBeenCalledTimes(1)
expect(displayedDetailsMap.value).toEqual({ 0: 'report' })
})
it('calls refetchSystemStats when not loading and stats are missing', async () => {
const store = await getStore()
mocks.refetchSystemStats.mockImplementation(async () => {
store.__setSystemStats(sampleSystemStats)
})
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockReturnValue('report')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
useErrorReport(card)
await flushPromises()
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
expect(mocks.generateErrorReport).toHaveBeenCalledTimes(1)
})
it('returns early and warns when refetchSystemStats throws', async () => {
mocks.refetchSystemStats.mockRejectedValue(new Error('boom'))
mocks.getLogs.mockResolvedValue('logs')
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
useErrorReport(card)
await flushPromises()
expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1)
expect(mocks.getLogs).not.toHaveBeenCalled()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
})
it('returns early and warns when workflow serialization throws', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockImplementation(() => {
throw new Error('serialize failed')
})
const card = makeCard({
errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(mocks.generateErrorReport).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'trace' })
})
it('falls back to original error.details when generateErrorReport throws', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(() => {
throw new Error('generate failed')
})
const card = makeCard({
errors: [
{ message: 'runtime', details: 'fallback', isRuntimeError: true }
]
})
const { displayedDetailsMap } = useErrorReport(card)
await flushPromises()
expect(warnSpy).toHaveBeenCalled()
expect(displayedDetailsMap.value).toEqual({ 0: 'fallback' })
})
it('re-enriches and clears stale enriched details when the card ref changes', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.getLogs.mockResolvedValue('logs')
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionMessage }: { exceptionMessage: string }) =>
`report:${exceptionMessage}`
)
const cardRef = ref<ErrorCardData>(
makeCard({
id: 'first',
errors: [
{ message: 'first-err', details: 'first', isRuntimeError: true }
]
})
)
const { displayedDetailsMap } = useErrorReport(cardRef)
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'report:first-err' })
cardRef.value = makeCard({
id: 'second',
errors: [{ message: 'plain', details: 'plain-details' }]
})
await nextTick()
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'plain-details' })
})
it('drops stale results when the card changes mid-flight', async () => {
const store = await getStore()
store.__setSystemStats(sampleSystemStats)
mocks.serialize.mockReturnValue({ nodes: [] })
mocks.generateErrorReport.mockImplementation(
({ exceptionMessage }: { exceptionMessage: string }) =>
`report:${exceptionMessage}`
)
const firstLogsDeferred: {
resolve: (value: string) => void
promise: Promise<string>
} = (() => {
let resolve: (value: string) => void = () => {}
const promise = new Promise<string>((r) => {
resolve = r
})
return { resolve, promise }
})()
mocks.getLogs.mockImplementationOnce(() => firstLogsDeferred.promise)
mocks.getLogs.mockImplementationOnce(async () => 'second-logs')
const cardRef = ref<ErrorCardData>(
makeCard({
id: 'first',
errors: [
{ message: 'first-err', details: 'first', isRuntimeError: true }
]
})
)
const { displayedDetailsMap } = useErrorReport(cardRef)
await flushPromises()
cardRef.value = makeCard({
id: 'second',
errors: [
{ message: 'second-err', details: 'second', isRuntimeError: true }
]
})
await nextTick()
await flushPromises()
firstLogsDeferred.resolve('stale-logs')
await flushPromises()
expect(displayedDetailsMap.value).toEqual({ 0: 'report:second-err' })
})
})

View File

@@ -87,7 +87,7 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget, sourceNode)
controlWidget: getControlWidget(sourceWidget)
}
})

View File

@@ -2,7 +2,6 @@
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
@@ -103,13 +103,11 @@ vi.mock('@/stores/authStore', () => ({
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
isActiveSubscription: { value: true },
subscriptionTierName: { value: 'Creator' },
subscriptionTier: { value: 'CREATOR' },
fetchStatus: mockFetchStatus
}))
}))
@@ -190,7 +188,6 @@ describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
@@ -409,43 +406,14 @@ describe('CurrentUserPopoverLegacy', () => {
})
})
describe('cloud free tier', () => {
beforeEach(() => {
mockIsCloud.value = true
mockIsFreeTier.value = true
})
it('shows upgrade-to-add-credits button and hides add-credits button', () => {
renderComponent()
expect(
screen.getByTestId('upgrade-to-add-credits-button')
).toBeInTheDocument()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
})
})
describe('non-cloud distribution', () => {
beforeEach(() => {
mockIsCloud.value = false
})
it('still shows credits balance', () => {
it('hides credits section', () => {
renderComponent()
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows add-credits button and hides upgrade-to-add-credits button', () => {
renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
})
it('hides upgrade-to-add-credits button even when on free tier', () => {
mockIsFreeTier.value = true
renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
@@ -456,9 +424,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {
it('hides partner nodes menu item', () => {
renderComponent()
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
expect(
screen.queryByTestId('partner-nodes-menu-item')
).not.toBeInTheDocument()
})
it('hides plans & pricing menu item', () => {
@@ -468,9 +438,11 @@ describe('CurrentUserPopoverLegacy', () => {
).not.toBeInTheDocument()
})
it('still shows manage plan menu item', () => {
it('hides manage plan menu item', () => {
renderComponent()
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
expect(
screen.queryByTestId('manage-plan-menu-item')
).not.toBeInTheDocument()
})
it('still shows user settings menu item', () => {

View File

@@ -29,8 +29,11 @@
</span>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<!-- Credits Section (cloud only) -->
<div
v-if="isCloud && isActiveSubscription"
class="flex items-center gap-2 px-4 py-2"
>
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
@@ -46,7 +49,7 @@
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
<Button
v-if="isCloud && isFreeTier"
v-if="isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@@ -79,7 +82,7 @@
<Divider class="mx-0 my-2" />
<div
v-if="isActiveSubscription"
v-if="isCloud && isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
@@ -109,7 +112,7 @@
</div>
<div
v-if="isActiveSubscription"
v-if="isCloud && isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"

View File

@@ -26,7 +26,6 @@ import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { IS_CONTROL_WIDGET, findExternalControlWidget } from '@/scripts/widgets'
import type {
LGraph,
@@ -151,28 +150,15 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
}
export function getControlWidget(
widget: IBaseWidget,
node?: LGraphNode
widget: IBaseWidget
): SafeControlWidget | undefined {
// Prefer the marker symbol so group/primitive nodes that prefix the widget
// name (e.g. "KSampler control_after_generate") still resolve.
const cagWidget =
widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET]) ??
widget.linkedWidgets?.find((w) => w.name === 'control_after_generate')
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
if (!cagWidget) return
const externalControl = node
? findExternalControlWidget(node, cagWidget)
: undefined
const displayWidget = externalControl ?? cagWidget
return {
value: normalizeControlOption(displayWidget.value),
update: (value) => {
const normalized = normalizeControlOption(value)
cagWidget.value = normalized
if (externalControl) externalControl.value = normalized
}
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
@@ -188,7 +174,7 @@ function getSharedWidgetEnhancements(
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget, node),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}

View File

@@ -1,243 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDragAndDrop } from './useNodeDragAndDrop'
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
...overrides
})
}
function createFile(name: string, type = 'image/png'): File {
return new File(['data'], name, { type })
}
function createDragEvent(options: {
items?: Array<{ kind: string }>
files?: File[]
types?: string[]
uri?: string
}): DragEvent {
const { items = [], files = [], types = [], uri = '' } = options
return fromAny<DragEvent, unknown>({
dataTransfer: {
items: fromAny<DataTransferItemList, unknown>(items),
files: fromAny<FileList, unknown>(files),
types,
getData: vi.fn((format: string) =>
format === 'text/uri-list' ? uri : ''
)
}
})
}
describe('useNodeDragAndDrop', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('onDragOver detects file items by default', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const isDragging = node.onDragOver?.(
createDragEvent({ items: [{ kind: 'file' }] })
)
expect(isDragging).toBe(true)
})
it('onDragOver delegates to custom handler result', () => {
const node = createNode()
const onDragOver = vi.fn().mockReturnValue(false)
useNodeDragAndDrop(node, {
onDrop: vi.fn().mockResolvedValue([]),
onDragOver
})
const isDragging = node.onDragOver?.(
createDragEvent({ items: [{ kind: 'file' }] })
)
expect(onDragOver).toHaveBeenCalledTimes(1)
expect(isDragging).toBe(false)
})
it('onDragOver returns true for uri list drops without file items', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const isDragging = node.onDragOver?.(
createDragEvent({ items: [{ kind: 'string' }], types: ['text/uri-list'] })
)
expect(isDragging).toBe(true)
})
it('onDragOver returns false when drag event has no items', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const isDragging = node.onDragOver?.(fromAny<DragEvent, unknown>({}))
expect(isDragging).toBe(false)
})
it('onDragDrop calls onDrop with filtered files', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()
const keep = createFile('keep.png')
const skip = createFile('skip.jpg', 'image/jpeg')
useNodeDragAndDrop(node, {
onDrop,
fileFilter: (file) => file.type === 'image/png'
})
const result = await node.onDragDrop?.(
createDragEvent({ files: [keep, skip], items: [{ kind: 'file' }] })
)
expect(result).toBe(true)
expect(onDrop).toHaveBeenCalledWith([keep])
})
it('onDragDrop returns false for invalid drops', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(createDragEvent({}))
expect(result).toBe(false)
expect(onDrop).not.toHaveBeenCalled()
})
it('onDragDrop handles same-origin uri drops', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
})
)
const uri = `${location.origin}/api/file?filename=uri.png`
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(result).toBe(true)
expect(fetchSpy).toHaveBeenCalledWith(new URL(uri))
expect(onDrop).toHaveBeenCalledTimes(1)
expect(onDrop.mock.calls[0][0][0]).toBeInstanceOf(File)
expect(onDrop.mock.calls[0][0][0].name).toBe('uri.png')
})
it('onDragDrop returns false for cross-origin uri drops', async () => {
const node = createNode()
const onDrop = vi.fn().mockResolvedValue([])
const fetchSpy = vi.spyOn(globalThis, 'fetch')
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(
createDragEvent({
uri: 'https://example.com/api/file?filename=uri.png',
types: ['text/uri-list']
})
)
expect(result).toBe(false)
expect(fetchSpy).not.toHaveBeenCalled()
expect(onDrop).not.toHaveBeenCalled()
})
it('onDragDrop returns false when uri fetch throws', async () => {
const onDrop = vi.fn().mockResolvedValue([])
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'))
const uri = `${location.origin}/api/file?filename=uri.png`
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(result).toBe(false)
expect(onDrop).not.toHaveBeenCalled()
})
it('onDragDrop returns false when uri response is invalid or filtered out', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const uri = `${location.origin}/api/file?filename=uri.jpg`
const nodeA = createNode()
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
fromAny<Response, unknown>({ ok: false })
)
useNodeDragAndDrop(nodeA, { onDrop })
const badResponseResult = await nodeA.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(badResponseResult).toBe(false)
const nodeB = createNode()
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/jpeg' }))
})
)
useNodeDragAndDrop(nodeB, {
onDrop,
fileFilter: (file) => file.type === 'image/png'
})
const filteredOutResult = await nodeB.onDragDrop?.(
createDragEvent({ uri, types: ['text/uri-list'] })
)
expect(filteredOutResult).toBe(false)
expect(onDrop).not.toHaveBeenCalled()
})
it('onRemoved clears handlers and chains existing onRemoved', () => {
const previousOnRemoved = vi.fn()
const node = createNode({ onRemoved: previousOnRemoved })
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
expect(node.onDragOver).toBeTypeOf('function')
expect(node.onDragDrop).toBeTypeOf('function')
node.onRemoved?.call(node)
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
expect(node.onDragOver).toBeUndefined()
expect(node.onDragDrop).toBeUndefined()
})
it('onRemoved preserves handlers replaced by another extension', () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const replacementDragOver = vi.fn()
const replacementDragDrop = vi.fn()
node.onDragOver = replacementDragOver
node.onDragDrop = replacementDragDrop
node.onRemoved?.call(node)
expect(node.onDragOver).toBe(replacementDragOver)
expect(node.onDragDrop).toBe(replacementDragDrop)
})
})

View File

@@ -1,4 +1,3 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type DragHandler = (e: DragEvent) => boolean
@@ -44,10 +43,9 @@ export const useNodeDragAndDrop = <T>(
return !!e?.dataTransfer?.getData('text/uri-list')
}
const installedDragOver = isDraggingFiles
node.onDragOver = installedDragOver
node.onDragOver = isDraggingFiles
const installedDragDrop = async function (e: DragEvent) {
node.onDragDrop = async function (e: DragEvent) {
if (!isDraggingValidFiles(e)) return false
const files = filterFiles(e.dataTransfer!.files)
@@ -75,10 +73,4 @@ export const useNodeDragAndDrop = <T>(
}
return true
}
node.onDragDrop = installedDragDrop
node.onRemoved = useChainCallback(node.onRemoved, () => {
if (node.onDragOver === installedDragOver) node.onDragOver = undefined
if (node.onDragDrop === installedDragDrop) node.onDragDrop = undefined
})
}

View File

@@ -1,175 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeFileInput } from './useNodeFileInput'
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
...overrides
})
}
function createFile(name: string, type = 'image/png'): File {
return new File(['data'], name, { type })
}
function setInputFiles(input: HTMLInputElement, files: File[]) {
Object.defineProperty(input, 'files', {
configurable: true,
value: fromAny<FileList, unknown>(files)
})
}
function setInputValue(input: HTMLInputElement, value: string) {
Object.defineProperty(input, 'value', {
configurable: true,
writable: true,
value
})
}
describe('useNodeFileInput', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('creates a file input with configured attributes and defaults', () => {
const fileInput = document.createElement('input')
const createElementSpy = vi
.spyOn(document, 'createElement')
.mockReturnValue(fileInput)
const node = createNode()
useNodeFileInput(node, { onSelect: vi.fn() })
expect(createElementSpy).toHaveBeenCalledWith('input')
expect(fileInput.type).toBe('file')
expect(fileInput.accept).toBe('*')
expect(fileInput.multiple).toBe(false)
})
it('uses provided accept and allow_batch options', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const node = createNode()
useNodeFileInput(node, {
onSelect: vi.fn(),
accept: 'image/*',
allow_batch: true
})
expect(fileInput.accept).toBe('image/*')
expect(fileInput.multiple).toBe(true)
})
it('calls onSelect with filtered files and resets value on change', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const onSelect = vi.fn()
const node = createNode()
const keep = createFile('keep.png')
const skip = createFile('skip.jpg', 'image/jpeg')
useNodeFileInput(node, {
onSelect,
fileFilter: (file) => file.type === 'image/png'
})
setInputFiles(fileInput, [keep, skip])
setInputValue(fileInput, 'C:\\fakepath\\keep.png')
fileInput.onchange?.(new Event('change'))
expect(onSelect).toHaveBeenCalledWith([keep])
expect(fileInput.value).toBe('')
})
it('does not call onSelect for empty file list and still resets value', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const onSelect = vi.fn()
const node = createNode()
useNodeFileInput(node, { onSelect })
setInputFiles(fileInput, [])
setInputValue(fileInput, 'C:\\fakepath\\empty.png')
fileInput.onchange?.(new Event('change'))
expect(onSelect).not.toHaveBeenCalled()
expect(fileInput.value).toBe('')
})
it('resets value before invoking onSelect so it is cleared even on throw', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const node = createNode()
const onSelect = vi.fn(() => {
throw new Error('boom')
})
useNodeFileInput(node, { onSelect })
setInputFiles(fileInput, [createFile('test.png')])
setInputValue(fileInput, 'C:\\fakepath\\test.png')
expect(() => fileInput.onchange?.(new Event('change'))).toThrow('boom')
expect(fileInput.value).toBe('')
})
it('does not call onSelect when all files are filtered out', () => {
const fileInput = document.createElement('input')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const onSelect = vi.fn()
const node = createNode()
useNodeFileInput(node, {
onSelect,
fileFilter: () => false
})
setInputFiles(fileInput, [createFile('ignored.png')])
fileInput.onchange?.(new Event('change'))
expect(onSelect).not.toHaveBeenCalled()
})
it('openFileSelection clicks the generated input', () => {
const fileInput = document.createElement('input')
const clickSpy = vi.spyOn(fileInput, 'click')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const node = createNode()
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
openFileSelection()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
it('cleans up on removal, chains existing callback, and no-ops after removal', () => {
const fileInput = document.createElement('input')
const clickSpy = vi.spyOn(fileInput, 'click')
vi.spyOn(document, 'createElement').mockReturnValue(fileInput)
const previousOnRemoved = vi.fn()
const node = createNode({ onRemoved: previousOnRemoved })
const { openFileSelection } = useNodeFileInput(node, { onSelect: vi.fn() })
expect(fileInput.onchange).toBeTypeOf('function')
node.onRemoved?.call(node)
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
expect(fileInput.onchange).toBeNull()
openFileSelection()
expect(clickSpy).not.toHaveBeenCalled()
})
})

View File

@@ -25,12 +25,10 @@ export function useNodeFileInput(node: LGraphNode, options: FileInputOptions) {
fileInput.multiple = allow_batch
fileInput.onchange = () => {
const files = fileInput?.files?.length
? Array.from(fileInput.files).filter(fileFilter)
: []
// Reset value so re-selecting the same file triggers onchange
if (fileInput) fileInput.value = ''
if (files.length) onSelect(files)
if (fileInput?.files?.length) {
const files = Array.from(fileInput.files).filter(fileFilter)
if (files.length) onSelect(files)
}
}
node.onRemoved = useChainCallback(node.onRemoved, () => {

View File

@@ -1,107 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodePaste } from './useNodePaste'
function createNode(overrides: Record<string, unknown> = {}): LGraphNode {
return fromAny<LGraphNode, unknown>({
...overrides
})
}
function createFile(name: string, type = 'image/png'): File {
return new File(['data'], name, { type })
}
describe('useNodePaste', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('pasteFiles calls onPaste with filtered files', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
const keep = createFile('keep.png')
const skip = createFile('skip.jpg', 'image/jpeg')
useNodePaste(node, {
onPaste,
fileFilter: (file) => file.type === 'image/png',
allow_batch: true
})
const result = node.pasteFiles?.([keep, skip])
expect(result).toBe(true)
expect(onPaste).toHaveBeenCalledWith([keep])
})
it('pasteFiles returns false when no files match filter', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
useNodePaste(node, {
onPaste,
fileFilter: () => false
})
const result = node.pasteFiles?.([createFile('ignored.png')])
expect(result).toBe(false)
expect(onPaste).not.toHaveBeenCalled()
})
it('pasteFiles limits to first file when allow_batch is false', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
const first = createFile('first.png')
const second = createFile('second.png')
useNodePaste(node, { onPaste, allow_batch: false })
const result = node.pasteFiles?.([first, second])
expect(result).toBe(true)
expect(onPaste).toHaveBeenCalledWith([first])
})
it('pasteFiles passes all files when allow_batch is true', () => {
const onPaste = vi.fn().mockResolvedValue('ok')
const node = createNode()
const first = createFile('first.png')
const second = createFile('second.png')
useNodePaste(node, { onPaste, allow_batch: true })
const result = node.pasteFiles?.([first, second])
expect(result).toBe(true)
expect(onPaste).toHaveBeenCalledWith([first, second])
})
it('onRemoved clears pasteFiles and chains existing onRemoved', () => {
const previousOnRemoved = vi.fn()
const node = createNode({ onRemoved: previousOnRemoved })
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
expect(node.pasteFiles).toBeTypeOf('function')
node.onRemoved?.call(node)
expect(previousOnRemoved).toHaveBeenCalledTimes(1)
expect(node.pasteFiles).toBeUndefined()
})
it('onRemoved preserves pasteFiles replaced by another extension', () => {
const node = createNode()
useNodePaste(node, { onPaste: vi.fn().mockResolvedValue('ok') })
const replacementPasteFiles = vi.fn()
node.pasteFiles = replacementPasteFiles
node.onRemoved?.call(node)
expect(node.pasteFiles).toBe(replacementPasteFiles)
})
})

View File

@@ -1,4 +1,3 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type PasteHandler<T> = (files: File[]) => Promise<T>
@@ -18,7 +17,7 @@ export const useNodePaste = <T>(
) => {
const { onPaste, fileFilter = () => true, allow_batch = false } = options
const installedPasteFiles = function (files: File[]) {
node.pasteFiles = function (files: File[]) {
const filteredFiles = Array.from(files).filter(fileFilter)
if (!filteredFiles.length) return false
@@ -27,9 +26,4 @@ export const useNodePaste = <T>(
void onPaste(paste)
return true
}
node.pasteFiles = installedPasteFiles
node.onRemoved = useChainCallback(node.onRemoved, () => {
if (node.pasteFiles === installedPasteFiles) node.pasteFiles = undefined
})
}

View File

@@ -43,9 +43,11 @@ describe('useExternalLink', () => {
// Static URLs
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
expect(staticUrls.github).toBe('https://github.com/Comfy-Org/ComfyUI')
expect(staticUrls.github).toBe(
'https://github.com/comfyanonymous/ComfyUI'
)
expect(staticUrls.githubIssues).toBe(
'https://github.com/Comfy-Org/ComfyUI/issues'
'https://github.com/comfyanonymous/ComfyUI/issues'
)
expect(staticUrls.githubFrontend).toBe(
'https://github.com/Comfy-Org/ComfyUI_frontend'

View File

@@ -85,8 +85,8 @@ export function useExternalLink() {
const staticUrls = {
// Static external URLs
discord: 'https://www.comfy.org/discord',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues',
github: 'https://github.com/comfyanonymous/ComfyUI',
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',

View File

@@ -321,39 +321,6 @@ describe('useLoad3d', () => {
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
// unregisters the component widget from the DOM widget store. If
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
// cleanup is lost and the interactive UI persists with a stale reference.
it('chains node.onRemoved with a preexisting callback', async () => {
const existingOnRemoved = vi.fn()
mockNode.onRemoved = existingOnRemoved
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onRemoved?.()
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
})
it('chains node.onResize with a preexisting callback', async () => {
const existingOnResize = vi.fn()
mockNode.onResize = existingOnResize
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onResize?.([512, 512] as Size)
expect(existingOnResize).toHaveBeenCalledTimes(1)
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)

View File

@@ -4,7 +4,6 @@ import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
@@ -134,32 +133,30 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
await restoreConfigurationsFromNode(node)
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
node.onMouseEnter = function () {
load3d?.refreshViewport()
load3d?.updateStatusMouseOnNode(true)
})
}
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
node.onMouseLeave = function () {
load3d?.updateStatusMouseOnNode(false)
})
}
node.onResize = useChainCallback(node.onResize, () => {
node.onResize = function () {
load3d?.handleResize()
})
}
node.onDrawBackground = useChainCallback(
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
node.onDrawBackground = function () {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
)
}
node.onRemoved = useChainCallback(node.onRemoved, () => {
node.onRemoved = function () {
useLoad3dService().removeLoad3d(node)
pendingCallbacks.delete(node)
})
}
nodeToLoad3dMap.set(node, load3d)

View File

@@ -323,10 +323,6 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!(outputGroups?.[idx] == matchKey)) return
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -1,699 +0,0 @@
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { SceneModelManager } from './SceneModelManager'
function createMockRenderer(): THREE.WebGLRenderer {
return {
outputColorSpace: THREE.SRGBColorSpace,
dispose: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function createMockEventManager(): EventManagerInterface {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
function createManager(
overrides: {
scene?: THREE.Scene
eventManager?: EventManagerInterface
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = overrides.eventManager ?? createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const getActiveCamera = () => camera
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const manager = new SceneModelManager(
scene,
renderer,
eventManager,
getActiveCamera,
setupCamera,
setupGizmo
)
return {
manager,
scene,
renderer,
eventManager,
camera,
setupCamera,
setupGizmo
}
}
function createMeshModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
const group = new THREE.Group()
group.name = name
group.add(mesh)
return group
}
describe('SceneModelManager', () => {
describe('constructor', () => {
it('initializes default state', () => {
const { manager } = createManager()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.materialMode).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
expect(manager.appliedTexture).toBeNull()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
it('creates material instances', () => {
const { manager } = createManager()
expect(manager.normalMaterial).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.wireframeMaterial).toBeInstanceOf(THREE.MeshBasicMaterial)
expect(manager.wireframeMaterial.wireframe).toBe(true)
expect(manager.depthMaterial).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(manager.standardMaterial).toBeInstanceOf(
THREE.MeshStandardMaterial
)
})
})
describe('dispose', () => {
it('disposes all materials', () => {
const { manager } = createManager()
const normalDispose = vi.spyOn(manager.normalMaterial, 'dispose')
const standardDispose = vi.spyOn(manager.standardMaterial, 'dispose')
const wireframeDispose = vi.spyOn(manager.wireframeMaterial, 'dispose')
const depthDispose = vi.spyOn(manager.depthMaterial, 'dispose')
manager.dispose()
expect(normalDispose).toHaveBeenCalled()
expect(standardDispose).toHaveBeenCalled()
expect(wireframeDispose).toHaveBeenCalled()
expect(depthDispose).toHaveBeenCalled()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.dispose()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
})
describe('createSTLMaterial', () => {
it('returns a MeshStandardMaterial with expected properties', () => {
const { manager } = createManager()
const mat = manager.createSTLMaterial()
expect(mat).toBeInstanceOf(THREE.MeshStandardMaterial)
expect(mat.color.getHex()).toBe(0x808080)
expect(mat.metalness).toBe(0.1)
expect(mat.roughness).toBe(0.8)
expect(mat.side).toBe(THREE.DoubleSide)
})
})
describe('addModelToScene', () => {
it('adds the model to the scene and sets currentModel', () => {
const { manager, scene } = createManager()
const model = createMeshModel()
manager.addModelToScene(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
})
})
describe('setupModel', () => {
it('scales and positions the model, then adds to scene', async () => {
const { manager, scene, setupCamera } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalled()
})
it('does not skip materialMode when it differs from original', async () => {
const { manager } = createManager()
const model = createMeshModel()
// setupModel checks materialMode !== 'original' and calls
// setMaterialMode, but the guard `mode === this.materialMode`
// causes it to no-op. Then setupModelMaterials resets to 'original'.
manager.materialMode = 'wireframe'
const spy = vi.spyOn(manager, 'setMaterialMode')
await manager.setupModel(model)
// setMaterialMode is called with the stored mode and then 'original'
expect(spy).toHaveBeenCalledWith('wireframe')
expect(spy).toHaveBeenCalledWith('original')
})
it('applies current up direction if not original', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
manager.currentUpDirection = '+z'
await manager.setupModel(model)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'+z'
)
})
})
describe('setOriginalModel', () => {
it('stores the original model reference', () => {
const { manager } = createManager()
const model = new THREE.Group()
manager.setOriginalModel(model)
expect(manager.originalModel).toBe(model)
})
})
describe('clearModel', () => {
it('removes non-environment objects from scene', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const light = new THREE.DirectionalLight()
scene.add(light)
manager.clearModel()
expect(manager.currentModel).toBeNull()
expect(scene.children).toContain(light)
})
it('disposes mesh geometry and materials', async () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const geoDispose = vi.spyOn(mesh.geometry, 'dispose')
const matDispose = vi.spyOn(mesh.material as THREE.Material, 'dispose')
await manager.setupModel(model)
manager.clearModel()
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
})
describe('reset', () => {
it('resets all state to defaults', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalFileName = 'test.glb'
manager.originalURL = 'http://example.com/test.glb'
manager.originalModel = model
manager.reset()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.reset()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
it('removes and disposes skeleton helper', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mockHelper = new THREE.SkeletonHelper(model)
const helperDispose = vi.spyOn(mockHelper, 'dispose')
manager.skeletonHelper = mockHelper
scene.add(mockHelper)
manager.reset()
expect(helperDispose).toHaveBeenCalled()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
})
describe('setMaterialMode', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setMaterialMode('normal')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('does nothing when mode is unchanged', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
vi.mocked(eventManager.emitEvent).mockClear()
manager.setMaterialMode('original')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('switches to normal material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('normal')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.materialMode).toBe('normal')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'normal'
)
})
it('switches to wireframe material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('wireframe')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial)
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
it('switches to depth material', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
})
it('restores original material when switching back', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.setMaterialMode('normal')
manager.setMaterialMode('original')
expect(mesh.material).toBe(originalMat)
})
it('uses appliedTexture when no original material stored', async () => {
const { manager } = createManager()
const model = createMeshModel()
const texture = new THREE.Texture()
manager.appliedTexture = texture
manager.addModelToScene(model)
manager.materialMode = 'normal'
manager.setMaterialMode('original')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial)
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
})
it('sets renderer color space to SRGB for non-depth modes', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
manager.setMaterialMode('normal')
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
})
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalModel = new THREE.BufferGeometry()
;(manager.originalModel as THREE.BufferGeometry).setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 2, 2], 3)
)
manager.setMaterialMode('wireframe')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
})
describe('setupModelMaterials', () => {
it('stores original materials in the WeakMap', () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.currentModel = model
manager.setupModelMaterials(model)
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
})
})
describe('setUpDirection', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setUpDirection('+x')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('stores the original rotation on first call', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
expect(manager.originalRotation).not.toBeNull()
})
it('applies correct rotation for each direction', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const directions: Array<{
dir: '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
axis: 'x' | 'z'
value: number
}> = [
{ dir: '-x', axis: 'z', value: Math.PI / 2 },
{ dir: '+x', axis: 'z', value: -Math.PI / 2 },
{ dir: '-y', axis: 'x', value: Math.PI },
{ dir: '-z', axis: 'x', value: Math.PI / 2 },
{ dir: '+z', axis: 'x', value: -Math.PI / 2 }
]
for (const { dir, axis, value } of directions) {
manager.setUpDirection(dir)
expect(model.rotation[axis]).toBeCloseTo(value)
expect(manager.currentUpDirection).toBe(dir)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
dir
)
}
})
it('restores original rotation before applying new direction', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
const zAfterX = model.rotation.z
manager.setUpDirection('-z')
expect(model.rotation.x).toBeCloseTo(Math.PI / 2)
expect(model.rotation.z).not.toBeCloseTo(zAfterX)
})
it('emits upDirectionChange event', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('original')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'original'
)
})
})
describe('hasSkeleton', () => {
it('returns false when no current model', () => {
const { manager } = createManager()
expect(manager.hasSkeleton()).toBe(false)
})
it('returns false for model without skeleton', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.hasSkeleton()).toBe(false)
})
it('returns true for model with SkinnedMesh', () => {
const { manager } = createManager()
const group = new THREE.Group()
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial()
const bones = [new THREE.Bone(), new THREE.Bone()]
bones[0].add(bones[1])
const skeleton = new THREE.Skeleton(bones)
const skinnedMesh = new THREE.SkinnedMesh(geometry, material)
skinnedMesh.add(bones[0])
skinnedMesh.bind(skeleton)
group.add(skinnedMesh)
manager.currentModel = group
expect(manager.hasSkeleton()).toBe(true)
})
})
describe('setShowSkeleton', () => {
it('sets showSkeleton flag', () => {
const { manager } = createManager()
manager.setShowSkeleton(true)
expect(manager.showSkeleton).toBe(true)
})
it('emits skeletonVisibilityChange event', () => {
const { manager, eventManager } = createManager()
manager.setShowSkeleton(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'skeletonVisibilityChange',
true
)
})
it('hides existing skeleton helper when set to false', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(false)
expect(helper.visible).toBe(false)
})
it('shows existing skeleton helper when set to true', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
helper.visible = false
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(true)
expect(helper.visible).toBe(true)
})
})
describe('containsSplatMesh', () => {
it('returns false when no model', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for regular model', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for explicit null argument', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh(null)).toBe(false)
})
})
describe('PLY mode switching', () => {
function createPLYManager() {
const ctx = createManager()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
ctx.manager.standardMaterial.clone()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
ctx.scene.add(group)
ctx.manager.currentModel = group
ctx.manager.originalModel = geometry
return ctx
}
it('recreates model as point cloud', () => {
const { manager, scene, eventManager } = createPLYManager()
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
const points = mainModel!.children.find((c) => c instanceof THREE.Points)
expect(points).toBeInstanceOf(THREE.Points)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'pointCloud'
)
})
it('recreates model as wireframe mesh', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
let foundWireframe = false
mainModel!.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material instanceof THREE.MeshBasicMaterial
) {
foundWireframe = child.material.wireframe
}
})
expect(foundWireframe).toBe(true)
})
it('uses vertex colors when available', () => {
const { manager, scene } = createManager()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
new THREE.MeshBasicMaterial()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
scene.add(group)
manager.currentModel = group
manager.originalModel = geometry
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
const points = mainModel!.children.find(
(c) => c instanceof THREE.Points
) as THREE.Points
expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true)
})
it('removes old MainModel objects before adding new one', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModels = scene.children.filter((c) => c.name === 'MainModel')
expect(mainModels).toHaveLength(1)
})
})
})

View File

@@ -1,4 +1,3 @@
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
@@ -17,7 +16,7 @@ function drawSelectionBorder(
if (selectedItems.size <= 1) return
// Use the same bounds calculation as the toolbox
const bounds = createBounds(selectedItems, SELECTION_BOUNDS_PADDING)
const bounds = createBounds(selectedItems, 10)
if (!bounds) return
const [x, y, width, height] = bounds

View File

@@ -7,7 +7,6 @@ import type {
Point,
ISerialisedNode
} from '@/lib/litegraph/src/litegraph'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import {
LGraphNode,
LiteGraph,
@@ -654,47 +653,4 @@ describe('LGraphNode', () => {
)
})
})
describe('measure() collapsed branching', () => {
let out: Rect
beforeEach(() => {
out = [0, 0, 0, 0] as unknown as Rect
node.flags.collapsed = true
node.size[0] = 150
node.size[1] = 10
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
test('legacy mode uses NODE_TITLE_HEIGHT-based fallback when no ctx', () => {
LiteGraph.vueNodesMode = false
node.measure(out)
// No ctx → legacy collapsed branch falls back to NODE_COLLAPSED_WIDTH
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
})
test('Vue mode uses this.size directly for collapsed nodes', () => {
LiteGraph.vueNodesMode = true
node.measure(out)
// Vue mode collapsed takes the expanded-style branch
expect(out[2]).toBe(150)
expect(out[3]).toBe(10 + LiteGraph.NODE_TITLE_HEIGHT)
})
test('Vue mode expanded behaves identically to legacy expanded', () => {
LiteGraph.vueNodesMode = true
node.flags.collapsed = false
node.size[0] = 200
node.size[1] = 120
node.measure(out)
expect(out[2]).toBe(200)
expect(out[3]).toBe(120 + LiteGraph.NODE_TITLE_HEIGHT)
})
})
})

View File

@@ -2088,10 +2088,7 @@ export class LGraphNode
out[0] = this.pos[0]
out[1] = this.pos[1] + -titleHeight
// In Vue mode, `this.size` is kept in sync with the DOM-measured
// collapsed dimensions via ResizeObserver → layoutStore → useLayoutSync,
// so the expanded branch produces correct bounds for collapsed nodes too.
if (!this.flags?.collapsed || LiteGraph.vueNodesMode) {
if (!this.flags?.collapsed) {
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {

View File

@@ -584,8 +584,6 @@
"publishButton": "النشر على ComfyHub",
"publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.",
"publishFailedTitle": "فشل النشر",
"publishSuccessDescription": "تم نشر سير العمل الخاص بك على ComfyHub.",
"publishSuccessTitle": "تم النشر بنجاح",
"removeExampleImage": "إزالة الصورة النموذجية",
"selectAThumbnail": "اختر صورة مصغرة",
"shareAs": "مشاركة كـ",
@@ -1216,9 +1214,7 @@
"nothingToDelete": "لا يوجد ما يمكن حذفه",
"nothingToDuplicate": "لا يوجد ما يمكن نسخه",
"nothingToRename": "لا يوجد ما يمكن إعادة تسميته",
"off": "إيقاف",
"ok": "موافق",
"on": "تشغيل",
"openManager": "فتح المدير",
"openNewIssue": "فتح مشكلة جديدة",
"or": "أو",
@@ -1645,16 +1641,7 @@
"exportModel": "تصدير النموذج",
"exportRecording": "تصدير التسجيل",
"exportingModel": "جارٍ تصدير النموذج...",
"fitToViewer": "تكييف مع العارض",
"fov": "مجال الرؤية (FOV)",
"gizmo": {
"label": "أداة التحكم",
"reset": "إعادة ضبط التحويل",
"rotate": "تدوير",
"scale": "تغيير الحجم",
"toggle": "تبديل أداة التحكم",
"translate": "تحريك"
},
"hdri": {
"changeFile": "تغيير HDRI",
"intensity": "الشدة",
@@ -2249,7 +2236,6 @@
"Reve": "Reve",
"Rodin": "رودان",
"Runway": "رن واي",
"Sonilo": "Sonilo",
"Sora": "سورا",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2323,7 +2309,6 @@
"stable_cascade": "سلسلة ثابتة",
"string": "سلسلة نصية",
"style_model": "نموذج النمط",
"supir": "supir",
"text": "نص",
"textgen": "textgen",
"training": "تدريب",
@@ -2510,8 +2495,6 @@
"advancedInputs": "مدخلات متقدمة",
"bypass": "تجاوز",
"color": "لون العقدة",
"editSubgraph": "تعديل الرسم البياني الفرعي",
"editTitle": "تعديل العنوان",
"enterSubgraph": "دخول الرسم الفرعي",
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
"errorHelpGithub": "إرسال مشكلة على GitHub",
@@ -3462,9 +3445,7 @@
"failedToPurchaseCredits": "فشل في شراء الرصيد: {error}",
"failedToQueue": "فشل في الإضافة إلى قائمة الانتظار",
"failedToSaveDraft": "فشل في حفظ مسودة سير العمل",
"failedToSetGizmoMode": "فشل في تعيين وضع أداة التحكم",
"failedToToggleCamera": "فشل في تبديل الكاميرا",
"failedToToggleGizmo": "فشل في تبديل أداة التحكم",
"failedToToggleGrid": "فشل في تبديل الشبكة",
"failedToUpdateBackgroundColor": "فشل في تحديث لون الخلفية",
"failedToUpdateBackgroundImage": "فشل في تحديث صورة الخلفية",

View File

@@ -472,137 +472,6 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).",
"display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"first_frame": {
"name": "الإطار الأول",
"tooltip": "صورة الإطار الأول للفيديو."
},
"last_frame": {
"name": "الإطار الأخير",
"tooltip": "صورة الإطار الأخير للفيديو."
},
"model": {
"name": "النموذج",
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
},
"model_duration": {
"name": "المدة"
},
"model_generate_audio": {
"name": "توليد الصوت"
},
"model_prompt": {
"name": "الموجه"
},
"model_ratio": {
"name": "النسبة"
},
"model_resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "إنشاء أو تعديل أو تمديد فيديو باستخدام Seedance 2.0 مع صور أو فيديوهات أو صوتيات مرجعية. يدعم المراجع متعددة الوسائط، وتحرير الفيديو، وتمديد الفيديو.",
"display_name": "ByteDance Seedance 2.0 من مرجع إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"model": {
"name": "النموذج",
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
},
"model_duration": {
"name": "المدة"
},
"model_generate_audio": {
"name": "توليد الصوت"
},
"model_prompt": {
"name": "الموجه"
},
"model_ratio": {
"name": "النسبة"
},
"model_resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "إنشاء فيديو باستخدام نماذج Seedance 2.0 بناءً على موجه نصي.",
"display_name": "ByteDance Seedance 2.0 من نص إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"model": {
"name": "النموذج",
"tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة."
},
"model_duration": {
"name": "المدة"
},
"model_generate_audio": {
"name": "توليد الصوت"
},
"model_prompt": {
"name": "الموجه"
},
"model_ratio": {
"name": "النسبة"
},
"model_resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"watermark": {
"name": "علامة مائية",
"tooltip": "هل تريد إضافة علامة مائية إلى الفيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.",
"display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو",
@@ -1493,36 +1362,6 @@
}
}
},
"ColorTransfer": {
"description": "مطابقة ألوان صورة مع أخرى باستخدام خوارزميات متنوعة.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "الصورة أو الصور المرجعية لمطابقة الألوان معها. إذا لم يتم توفيرها، سيتم تخطي المعالجة."
},
"image_target": {
"name": "image_target",
"tooltip": "الصورة أو الصور التي سيتم تطبيق تحويل الألوان عليها."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: كل إطار يُطابق مع image_ref بشكل فردي. uniform: تجميع إحصائيات جميع الإطارات المصدرية كأساس، والمطابقة مع image_ref. target_frame: استخدام إطار محدد كأساس للتحويل إلى image_ref، ويُطبق بشكل موحد على جميع الإطارات (يحافظ على الفروقات النسبية)."
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "دمج الخطافات [2]",
"inputs": {
@@ -5622,22 +5461,6 @@
}
}
},
"JsonExtractString": {
"display_name": "استخراج سلسلة من JSON",
"inputs": {
"json_string": {
"name": "سلسلة_json"
},
"key": {
"name": "مفتاح"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "يستخدم النموذج المقدم، والتوجيه الإيجابي والسلبي لإزالة الضجيج من الصورة الكامنة.",
"display_name": "KSampler",
@@ -13932,44 +13755,6 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "يسحب المخرجات المنزوعة الضوضاء نحو الإدخال الكامن. قيمة أعلى = تطابق أقوى مع الإدخال. ۰ لتعطيل الميزة."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "عتبة سيغما التي دونها يتم تعطيل restore_cfg."
},
"strength_end": {
"name": "strength_end",
"tooltip": "التحكم في قوة التأثير في نهاية العينة (سيغما منخفضة). يتم الاستيفاء خطياً من البداية."
},
"strength_start": {
"name": "strength_start",
"tooltip": "التحكم في قوة التأثير في بداية العينة (سيغما عالية)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "تهيئة SV3D",
"inputs": {
@@ -14976,58 +14761,6 @@
}
}
},
"SoniloTextToMusic": {
"description": "إنشاء موسيقى من وصف نصي باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. اترك المدة ۰ ليقوم النموذج بتحديدها تلقائياً من الوصف.",
"display_name": "تحويل النص إلى موسيقى بواسطة Sonilo",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"duration": {
"name": "المدة",
"tooltip": "المدة المستهدفة بالثواني. ضع القيمة ۰ ليقوم النموذج بتحديد المدة تلقائياً من الوصف. الحد الأقصى: ٦ دقائق."
},
"prompt": {
"name": "الوصف",
"tooltip": "وصف نصي يصف الموسيقى المطلوب إنشاؤها."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "إنشاء موسيقى من محتوى الفيديو باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. يقوم بتحليل الفيديو وإنشاء موسيقى متوافقة.",
"display_name": "تحويل الفيديو إلى موسيقى بواسطة Sonilo",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد الإنشاء"
},
"prompt": {
"name": "الوصف",
"tooltip": "وصف نصي اختياري لتوجيه إنشاء الموسيقى. اتركه فارغاً للحصول على أفضل جودة - سيقوم النموذج بتحليل محتوى الفيديو بالكامل."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني."
},
"video": {
"name": "الفيديو",
"tooltip": "فيديو الإدخال لإنشاء الموسيقى منه. الحد الأقصى للمدة: ٦ دقائق."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "يفصل الصوت إلى القناتين اليسرى واليمنى.",
"display_name": "فصل قنوات الصوت",
@@ -16292,10 +16025,6 @@
"thinking": {
"name": "التفكير",
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
},
"use_default_template": {
"name": "استخدام القالب الافتراضي",
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
}
},
"outputs": {
@@ -16347,10 +16076,6 @@
"thinking": {
"name": "التفكير",
"tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك."
},
"use_default_template": {
"name": "استخدام القالب الافتراضي",
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
}
},
"outputs": {

View File

@@ -1670,7 +1670,6 @@
"attention_experiments": "attention_experiments",
"flux": "flux",
"kandinsky5": "kandinsky5",
"postprocessing": "postprocessing",
"hooks": "hooks",
"combine": "combine",
"math": "math",
@@ -1702,6 +1701,7 @@
"HitPaw": "HitPaw",
"sd": "sd",
"Ideogram": "Ideogram",
"postprocessing": "postprocessing",
"transform": "transform",
"batch": "batch",
"upscaling": "upscaling",
@@ -1736,12 +1736,10 @@
"save": "save",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"Sonilo": "Sonilo",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model",
"supir": "supir",
"Tencent": "Tencent",
"textgen": "textgen",
"Topaz": "Topaz",

View File

@@ -472,137 +472,6 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video",
"description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
},
"first_frame": {
"name": "first_frame",
"tooltip": "First frame image for the video."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add a watermark to the video."
},
"last_frame": {
"name": "last_frame",
"tooltip": "Last frame image for the video."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
},
"model_generate_audio": {
"name": "generate_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"display_name": "ByteDance Seedance 2.0 Reference to Video",
"description": "Generate, edit, or extend video using Seedance 2.0 with reference images, videos, and audio. Supports multimodal reference, video editing, and video extension.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add a watermark to the video."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
},
"model_generate_audio": {
"name": "generate_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"display_name": "ByteDance Seedance 2.0 Text to Video",
"description": "Generate video using Seedance 2.0 models based on a text prompt.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add a watermark to the video."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
},
"model_generate_audio": {
"name": "generate_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"display_name": "ByteDance First-Last-Frame to Video",
"description": "Generate video using prompt and first and last frames.",
@@ -1493,36 +1362,6 @@
}
}
},
"ColorTransfer": {
"display_name": "ColorTransfer",
"description": "Match the colors of one image to another using various algorithms.",
"inputs": {
"image_target": {
"name": "image_target",
"tooltip": "Image(s) to apply the color transform to."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)"
},
"strength": {
"name": "strength"
},
"image_ref": {
"name": "image_ref",
"tooltip": "Reference image(s) to match colors to. If not provided, processing is skipped"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "Combine Hooks [2]",
"inputs": {
@@ -5622,22 +5461,6 @@
}
}
},
"JsonExtractString": {
"display_name": "Extract String from JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Kandinsky5ImageToVideo": {
"display_name": "Kandinsky5ImageToVideo",
"inputs": {
@@ -14855,58 +14678,6 @@
}
}
},
"SoniloTextToMusic": {
"display_name": "Sonilo Text to Music",
"description": "Generate music from a text prompt using Sonilo's AI model. Leave duration at 0 to let the model infer it from the prompt.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt describing the music to generate."
},
"duration": {
"name": "duration",
"tooltip": "Target duration in seconds. Set to 0 to let the model infer the duration from the prompt. Maximum: 6 minutes."
},
"seed": {
"name": "seed",
"tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"display_name": "Sonilo Video to Music",
"description": "Generate music from video content using Sonilo's AI model. Analyzes the video and creates matching music.",
"inputs": {
"video": {
"name": "video",
"tooltip": "Input video to generate music from. Maximum duration: 6 minutes."
},
"prompt": {
"name": "prompt",
"tooltip": "Optional text prompt to guide music generation. Leave empty for best quality - the model will fully analyze the video content."
},
"seed": {
"name": "seed",
"tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"display_name": "Split Audio Channels",
"description": "Separates the audio into left and right channels.",
@@ -15650,44 +15421,6 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"vae": {
"name": "vae"
},
"image": {
"name": "image"
},
"strength_start": {
"name": "strength_start",
"tooltip": "Control strength at the start of sampling (high sigma)."
},
"strength_end": {
"name": "strength_end",
"tooltip": "Control strength at the end of sampling (low sigma). Linearly interpolated from start."
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "Pulls denoised output toward the input latent. Higher = stronger fidelity to input. 0 to disable."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "Sigma threshold below which restore_cfg is disabled."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "SV3D_Conditioning",
"inputs": {
@@ -16272,10 +16005,6 @@
"name": "thinking",
"tooltip": "Operate in thinking mode if the model supports it."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Use the built in system prompt/template if the model has one."
},
"sampling_mode_min_p": {
"name": "min_p"
},
@@ -16327,10 +16056,6 @@
"name": "thinking",
"tooltip": "Operate in thinking mode if the model supports it."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Use the built in system prompt/template if the model has one."
},
"sampling_mode_min_p": {
"name": "min_p"
},

View File

@@ -584,8 +584,6 @@
"publishButton": "Publicar en ComfyHub",
"publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.",
"publishFailedTitle": "Error al publicar",
"publishSuccessDescription": "Tu flujo de trabajo ya está disponible en ComfyHub.",
"publishSuccessTitle": "Publicado con éxito",
"removeExampleImage": "Eliminar imagen de ejemplo",
"selectAThumbnail": "Selecciona una miniatura",
"shareAs": "Compartir como",
@@ -1216,9 +1214,7 @@
"nothingToDelete": "Nada para eliminar",
"nothingToDuplicate": "Nada para duplicar",
"nothingToRename": "Nada para renombrar",
"off": "Apagado",
"ok": "OK",
"on": "Encendido",
"openManager": "Abrir administrador",
"openNewIssue": "Abrir nuevo problema",
"or": "o",
@@ -1645,16 +1641,7 @@
"exportModel": "Exportar modelo",
"exportRecording": "Exportar grabación",
"exportingModel": "Exportando modelo...",
"fitToViewer": "Ajustar al visor",
"fov": "FOV",
"gizmo": {
"label": "Gizmo",
"reset": "Restablecer transformación",
"rotate": "Rotar",
"scale": "Escalar",
"toggle": "Gizmo",
"translate": "Trasladar"
},
"hdri": {
"changeFile": "Cambiar HDRI",
"intensity": "Intensidad",
@@ -2249,7 +2236,6 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2323,7 +2309,6 @@
"stable_cascade": "stable_cascade",
"string": "cadena",
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
"textgen": "textgen",
"training": "entrenamiento",
@@ -2510,8 +2495,6 @@
"advancedInputs": "ENTRADAS AVANZADAS",
"bypass": "Omitir",
"color": "Color del nodo",
"editSubgraph": "Editar subgrafo",
"editTitle": "Editar título",
"enterSubgraph": "Entrar en subgrafo",
"errorHelp": "Para más ayuda, {github} o {support}",
"errorHelpGithub": "envía un issue en GitHub",
@@ -3462,9 +3445,7 @@
"failedToPurchaseCredits": "No se pudo comprar créditos: {error}",
"failedToQueue": "Error al encolar",
"failedToSaveDraft": "No se pudo guardar el borrador del flujo de trabajo",
"failedToSetGizmoMode": "No se pudo establecer el modo de gizmo",
"failedToToggleCamera": "No se pudo alternar la cámara",
"failedToToggleGizmo": "No se pudo alternar el gizmo",
"failedToToggleGrid": "No se pudo alternar la cuadrícula",
"failedToUpdateBackgroundColor": "No se pudo actualizar el color de fondo",
"failedToUpdateBackgroundImage": "No se pudo actualizar la imagen de fondo",

View File

@@ -472,137 +472,6 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.",
"display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"first_frame": {
"name": "primer_fotograma",
"tooltip": "Imagen del primer fotograma para el video."
},
"last_frame": {
"name": "último_fotograma",
"tooltip": "Imagen del último fotograma para el video."
},
"model": {
"name": "modelo",
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
},
"model_duration": {
"name": "duración"
},
"model_generate_audio": {
"name": "generar_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "relación"
},
"model_resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"watermark": {
"name": "marca_de_agua",
"tooltip": "Indica si se debe añadir una marca de agua al video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "Genera, edita o extiende video usando Seedance 2.0 con imágenes, videos y audio de referencia. Soporta referencia multimodal, edición de video y extensión de video.",
"display_name": "ByteDance Seedance 2.0 Referencia a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
},
"model_duration": {
"name": "duración"
},
"model_generate_audio": {
"name": "generar_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "relación"
},
"model_resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"watermark": {
"name": "marca_de_agua",
"tooltip": "Indica si se debe añadir una marca de agua al video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "Genera video usando modelos Seedance 2.0 a partir de un prompt de texto.",
"display_name": "ByteDance Seedance 2.0 Texto a Video",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad."
},
"model_duration": {
"name": "duración"
},
"model_generate_audio": {
"name": "generar_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "relación"
},
"model_resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"watermark": {
"name": "marca_de_agua",
"tooltip": "Indica si se debe añadir una marca de agua al video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "Generar video usando prompt y primer y último fotograma.",
"display_name": "ByteDance Primer-Último-Fotograma a Video",
@@ -1493,36 +1362,6 @@
}
}
},
"ColorTransfer": {
"description": "Iguala los colores de una imagen con otra utilizando varios algoritmos.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "Imagen(es) de referencia para igualar los colores. Si no se proporciona, se omite el procesamiento."
},
"image_target": {
"name": "image_target",
"tooltip": "Imagen(es) a las que se aplicará la transferencia de color."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: cada fotograma se iguala individualmente a image_ref. uniform: agrupa estadísticas de todos los fotogramas fuente como referencia, iguala a image_ref. target_frame: usa un fotograma elegido como referencia para la transformación a image_ref, aplicado uniformemente a todos los fotogramas (preserva las diferencias relativas)"
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "Combinar Hooks [2]",
"inputs": {
@@ -5622,22 +5461,6 @@
}
}
},
"JsonExtractString": {
"display_name": "Extraer cadena de JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "Utiliza el modelo proporcionado, el acondicionamiento positivo y negativo para deshacer el ruido de la imagen latente.",
"display_name": "KSampler",
@@ -13932,44 +13755,6 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "Atrae la salida denoised hacia el latent de entrada. Un valor más alto = mayor fidelidad a la entrada. 0 para desactivar."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "Umbral de sigma por debajo del cual restore_cfg se desactiva."
},
"strength_end": {
"name": "strength_end",
"tooltip": "Controla la intensidad al final del muestreo (sigma baja). Interpolado linealmente desde el inicio."
},
"strength_start": {
"name": "strength_start",
"tooltip": "Controla la intensidad al inicio del muestreo (sigma alta)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "SV3D_Acondicionamiento",
"inputs": {
@@ -14976,58 +14761,6 @@
}
}
},
"SoniloTextToMusic": {
"description": "Genera música a partir de un texto usando el modelo de IA de Sonilo. Deja la duración en 0 para que el modelo la infiera del texto.",
"display_name": "Sonilo Texto a Música",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration",
"tooltip": "Duración objetivo en segundos. Pon 0 para que el modelo infiera la duración del texto. Máximo: 6 minutos."
},
"prompt": {
"name": "prompt",
"tooltip": "Texto descriptivo de la música a generar."
},
"seed": {
"name": "seed",
"tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "Genera música a partir de contenido de video usando el modelo de IA de Sonilo. Analiza el video y crea música acorde.",
"display_name": "Sonilo Video a Música",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"prompt": {
"name": "prompt",
"tooltip": "Texto opcional para guiar la generación musical. Déjalo vacío para mejor calidad: el modelo analizará completamente el contenido del video."
},
"seed": {
"name": "seed",
"tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo."
},
"video": {
"name": "video",
"tooltip": "Video de entrada del que generar música. Duración máxima: 6 minutos."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "Separa el audio en canales izquierdo y derecho.",
"display_name": "Separar canales de audio",
@@ -16292,10 +16025,6 @@
"thinking": {
"name": "pensando",
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno."
}
},
"outputs": {
@@ -16347,10 +16076,6 @@
"thinking": {
"name": "pensando",
"tooltip": "Operar en modo de pensamiento si el modelo lo permite."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno."
}
},
"outputs": {

View File

@@ -584,8 +584,6 @@
"publishButton": "انتشار در ComfyHub",
"publishFailedDescription": "در هنگام انتشار گردش‌کار شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"publishFailedTitle": "انتشار ناموفق بود",
"publishSuccessDescription": "گردش‌کار شما اکنون در ComfyHub فعال است.",
"publishSuccessTitle": "با موفقیت منتشر شد",
"removeExampleImage": "حذف تصویر نمونه",
"selectAThumbnail": "یک تصویر بندانگشتی انتخاب کنید",
"shareAs": "اشتراک‌گذاری به عنوان",
@@ -1216,9 +1214,7 @@
"nothingToDelete": "موردی برای حذف وجود ندارد",
"nothingToDuplicate": "موردی برای تکرار وجود ندارد",
"nothingToRename": "موردی برای تغییر نام وجود ندارد",
"off": "خاموش",
"ok": "تأیید",
"on": "روشن",
"openManager": "باز کردن مدیریت",
"openNewIssue": "ایجاد گزارش جدید",
"or": "یا",
@@ -1645,16 +1641,7 @@
"exportModel": "خروجی گرفتن مدل",
"exportRecording": "خروجی گرفتن ضبط",
"exportingModel": "در حال خروجی گرفتن مدل...",
"fitToViewer": "تنظیم بر اساس نمایشگر",
"fov": "زاویه دید (FOV)",
"gizmo": {
"label": "Gizmo",
"reset": "بازنشانی تغییرات",
"rotate": "چرخش",
"scale": "مقیاس",
"toggle": "فعال/غیرفعال کردن Gizmo",
"translate": "جابجایی"
},
"hdri": {
"changeFile": "تغییر HDRI",
"intensity": "شدت",
@@ -2249,7 +2236,6 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2323,7 +2309,6 @@
"stable_cascade": "stable cascade",
"string": "رشته",
"style_model": "مدل سبک",
"supir": "supir",
"text": "متن",
"textgen": "textgen",
"training": "آموزش",
@@ -2510,8 +2495,6 @@
"advancedInputs": "ورودی‌های پیشرفته",
"bypass": "عبور",
"color": "رنگ نود",
"editSubgraph": "ویرایش زیرگراف",
"editTitle": "ویرایش عنوان",
"enterSubgraph": "ورود به زیرگراف",
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
"errorHelpGithub": "ثبت یک issue در GitHub",
@@ -3474,9 +3457,7 @@
"failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}",
"failedToQueue": "صف‌بندی انجام نشد",
"failedToSaveDraft": "ذخیره پیش‌نویس workflow ناموفق بود",
"failedToSetGizmoMode": "تنظیم حالت Gizmo ناموفق بود",
"failedToToggleCamera": "تغییر وضعیت دوربین انجام نشد",
"failedToToggleGizmo": "فعال/غیرفعال کردن Gizmo ناموفق بود",
"failedToToggleGrid": "تغییر وضعیت شبکه انجام نشد",
"failedToUpdateBackgroundColor": "به‌روزرسانی رنگ پس‌زمینه انجام نشد",
"failedToUpdateBackgroundImage": "به‌روزرسانی تصویر پس‌زمینه انجام نشد",

View File

@@ -472,137 +472,6 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از Seedance 2.0 از تصویر اولین فریم و در صورت نیاز تصویر آخرین فریم.",
"display_name": "ByteDance Seedance 2.0 تبدیل اولین-آخرین فریم به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"first_frame": {
"name": "اولین فریم",
"tooltip": "تصویر اولین فریم برای ویدیو."
},
"last_frame": {
"name": "آخرین فریم",
"tooltip": "تصویر آخرین فریم برای ویدیو."
},
"model": {
"name": "مدل",
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت."
},
"model_duration": {
"name": "مدت زمان"
},
"model_generate_audio": {
"name": "تولید صدا"
},
"model_prompt": {
"name": "پرامپت"
},
"model_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"watermark": {
"name": "واترمارک",
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "تولید، ویرایش یا گسترش ویدیو با استفاده از Seedance 2.0 و تصاویر مرجع، ویدیوها و صدا. پشتیبانی از مرجع چندرسانه‌ای، ویرایش ویدیو و گسترش ویدیو.",
"display_name": "ByteDance Seedance 2.0 مرجع به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت."
},
"model_duration": {
"name": "مدت زمان"
},
"model_generate_audio": {
"name": "تولید صدا"
},
"model_prompt": {
"name": "پرامپت"
},
"model_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"watermark": {
"name": "واترمارک",
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "تولید ویدیو با استفاده از مدل‌های Seedance 2.0 بر اساس پرامپت متنی.",
"display_name": "ByteDance Seedance 2.0 متن به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت."
},
"model_duration": {
"name": "مدت زمان"
},
"model_generate_audio": {
"name": "تولید صدا"
},
"model_prompt": {
"name": "پرامپت"
},
"model_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"watermark": {
"name": "واترمارک",
"tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.",
"display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance",
@@ -1493,36 +1362,6 @@
}
}
},
"ColorTransfer": {
"description": "هماهنگ‌سازی رنگ‌های یک تصویر با تصویر دیگر با استفاده از الگوریتم‌های مختلف.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "تصویر(ها)ی مرجع برای هماهنگ‌سازی رنگ‌ها. در صورت عدم ارائه، پردازش انجام نمی‌شود."
},
"image_target": {
"name": "image_target",
"tooltip": "تصویر(ها)یی که تبدیل رنگ باید بر روی آن‌ها اعمال شود."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame: هر فریم به صورت جداگانه با image_ref هماهنگ می‌شود. uniform: آمار تمام فریم‌های منبع به عنوان مبنا جمع‌آوری شده و با image_ref هماهنگ می‌شود. target_frame: یک فریم انتخابی به عنوان مبنا برای تبدیل به image_ref استفاده می‌شود و به طور یکنواخت بر همه فریم‌ها اعمال می‌گردد (تفاوت‌های نسبی حفظ می‌شوند)."
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "ترکیب هوک‌ها [۲]",
"inputs": {
@@ -5622,22 +5461,6 @@
}
}
},
"JsonExtractString": {
"display_name": "استخراج رشته از JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "با استفاده از مدل ارائه‌شده و شرط‌های مثبت و منفی، تصویر نهفته را از نویز پاک‌سازی می‌کند.",
"display_name": "KSampler",
@@ -13932,44 +13755,6 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "خروجی نویززدایی‌شده را به سمت ورودی latent می‌کشاند. مقدار بالاتر = وفاداری بیشتر به ورودی. ۰ برای غیرفعال‌سازی."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "آستانه سیگما که زیر آن restore_cfg غیرفعال می‌شود."
},
"strength_end": {
"name": "strength_end",
"tooltip": "کنترل شدت در انتهای نمونه‌گیری (سیگما پایین). به صورت خطی از مقدار ابتدایی میان‌یابی می‌شود."
},
"strength_start": {
"name": "strength_start",
"tooltip": "کنترل شدت در ابتدای نمونه‌گیری (سیگما بالا)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "شرط‌گذاری SV3D",
"inputs": {
@@ -14976,58 +14761,6 @@
}
}
},
"SoniloTextToMusic": {
"description": "تولید موسیقی از یک پرامپت متنی با استفاده از مدل هوش مصنوعی Sonilo. مدت زمان را روی ۰ قرار دهید تا مدل آن را از پرامپت تشخیص دهد.",
"display_name": "تبدیل متن به موسیقی با Sonilo",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration",
"tooltip": "مدت زمان هدف به ثانیه. برای تشخیص خودکار مدت زمان توسط مدل، مقدار را روی ۰ قرار دهید. حداکثر: ۶ دقیقه."
},
"prompt": {
"name": "prompt",
"tooltip": "پرامپت متنی برای توصیف موسیقی مورد نظر جهت تولید."
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته می‌شود اما برای سازگاری گراف حفظ شده است."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "تولید موسیقی از محتوای ویدیویی با استفاده از مدل هوش مصنوعی Sonilo. ویدیو را تحلیل کرده و موسیقی متناسب ایجاد می‌کند.",
"display_name": "تبدیل ویدیو به موسیقی با Sonilo",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"prompt": {
"name": "prompt",
"tooltip": "پرامپت متنی اختیاری برای راهنمایی تولید موسیقی. برای بهترین کیفیت خالی بگذارید - مدل به طور کامل محتوای ویدیو را تحلیل می‌کند."
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته می‌شود اما برای سازگاری گراف حفظ شده است."
},
"video": {
"name": "video",
"tooltip": "ویدیوی ورودی برای تولید موسیقی. حداکثر مدت زمان: ۶ دقیقه."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "صدا را به کانال‌های چپ و راست جدا می‌کند.",
"display_name": "تقسیم کانال‌های صوتی",
@@ -16292,10 +16025,6 @@
"thinking": {
"name": "تفکر",
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود."
}
},
"outputs": {
@@ -16347,10 +16076,6 @@
"thinking": {
"name": "تفکر",
"tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند."
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود."
}
},
"outputs": {

View File

@@ -584,8 +584,6 @@
"publishButton": "Publier sur ComfyHub",
"publishFailedDescription": "Une erreur sest produite lors de la publication de votre workflow. Veuillez réessayer.",
"publishFailedTitle": "Échec de la publication",
"publishSuccessDescription": "Votre workflow est maintenant en ligne sur ComfyHub.",
"publishSuccessTitle": "Publication réussie",
"removeExampleImage": "Supprimer limage dexemple",
"selectAThumbnail": "Sélectionner une miniature",
"shareAs": "Partager en tant que",
@@ -1216,9 +1214,7 @@
"nothingToDelete": "Rien à supprimer",
"nothingToDuplicate": "Rien à dupliquer",
"nothingToRename": "Rien à renommer",
"off": "Désactivé",
"ok": "OK",
"on": "Activé",
"openManager": "Ouvrir le gestionnaire",
"openNewIssue": "Ouvrir un nouveau problème",
"or": "ou",
@@ -1645,16 +1641,7 @@
"exportModel": "Exportation du modèle",
"exportRecording": "Exporter l'enregistrement",
"exportingModel": "Exportation du modèle en cours...",
"fitToViewer": "Ajuster à la visionneuse",
"fov": "FOV",
"gizmo": {
"label": "Gizmo",
"reset": "Réinitialiser la transformation",
"rotate": "Pivoter",
"scale": "Échelle",
"toggle": "Gizmo",
"translate": "Déplacer"
},
"hdri": {
"changeFile": "Changer l'HDRI",
"intensity": "Intensité",
@@ -2249,7 +2236,6 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2323,7 +2309,6 @@
"stable_cascade": "stable_cascade",
"string": "chaîne",
"style_model": "modèle_de_style",
"supir": "supir",
"text": "texte",
"textgen": "textgen",
"training": "entraînement",
@@ -2510,8 +2495,6 @@
"advancedInputs": "ENTRÉES AVANCÉES",
"bypass": "Contourner",
"color": "Couleur du nœud",
"editSubgraph": "Modifier le sous-graphe",
"editTitle": "Modifier le titre",
"enterSubgraph": "Entrer dans le sous-graphe",
"errorHelp": "Pour plus d'aide, {github} ou {support}",
"errorHelpGithub": "soumettre un ticket GitHub",
@@ -3462,9 +3445,7 @@
"failedToPurchaseCredits": "Échec de l'achat de crédits : {error}",
"failedToQueue": "Échec de la mise en file d'attente",
"failedToSaveDraft": "Échec de lenregistrement du brouillon du flux de travail",
"failedToSetGizmoMode": "Échec du changement de mode du gizmo",
"failedToToggleCamera": "Échec de lactivation/désactivation de la caméra",
"failedToToggleGizmo": "Échec de lactivation du gizmo",
"failedToToggleGrid": "Échec de lactivation/désactivation de la grille",
"failedToUpdateBackgroundColor": "Échec de la mise à jour de la couleur darrière-plan",
"failedToUpdateBackgroundImage": "Échec de la mise à jour de limage darrière-plan",

View File

@@ -472,137 +472,6 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "Générez une vidéo avec Seedance 2.0 à partir d'une image de première image et, optionnellement, d'une image de dernière image.",
"display_name": "ByteDance Seedance 2.0 Première-Dernière-Image vers Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"first_frame": {
"name": "première image",
"tooltip": "Image de la première image pour la vidéo."
},
"last_frame": {
"name": "dernière image",
"tooltip": "Image de la dernière image pour la vidéo."
},
"model": {
"name": "modèle",
"tooltip": "Seedance 2.0 pour une qualité maximale ; Seedance 2.0 Fast pour une optimisation de la vitesse."
},
"model_duration": {
"name": "durée"
},
"model_generate_audio": {
"name": "générer_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "résolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"watermark": {
"name": "filigrane",
"tooltip": "Ajouter ou non un filigrane à la vidéo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "Générez, éditez ou étendez une vidéo avec Seedance 2.0 à l'aide d'images, de vidéos et d'audios de référence. Prend en charge la référence multimodale, l'édition vidéo et l'extension vidéo.",
"display_name": "ByteDance Seedance 2.0 Référence vers Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Seedance 2.0 pour une qualité maximale ; Seedance 2.0 Fast pour une optimisation de la vitesse."
},
"model_duration": {
"name": "durée"
},
"model_generate_audio": {
"name": "générer_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "résolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"watermark": {
"name": "filigrane",
"tooltip": "Ajouter ou non un filigrane à la vidéo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "Générez une vidéo avec les modèles Seedance 2.0 à partir d'un prompt textuel.",
"display_name": "ByteDance Seedance 2.0 Texte vers Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Seedance 2.0 pour une qualité maximale ; Seedance 2.0 Fast pour une optimisation de la vitesse."
},
"model_duration": {
"name": "durée"
},
"model_generate_audio": {
"name": "générer_audio"
},
"model_prompt": {
"name": "prompt"
},
"model_ratio": {
"name": "ratio"
},
"model_resolution": {
"name": "résolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"watermark": {
"name": "filigrane",
"tooltip": "Ajouter ou non un filigrane à la vidéo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "Générer une vidéo en utilisant l'invite et les première et dernière images.",
"display_name": "ByteDance Première-Dernière Image vers Vidéo",
@@ -1493,36 +1362,6 @@
}
}
},
"ColorTransfer": {
"description": "Faire correspondre les couleurs d'une image à une autre en utilisant divers algorithmes.",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "Image(s) de référence à laquelle faire correspondre les couleurs. Si non fourni, le traitement est ignoré."
},
"image_target": {
"name": "image_target",
"tooltip": "Image(s) auxquelles appliquer la transformation de couleur."
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame : chaque image est ajustée individuellement à image_ref. uniform : les statistiques de toutes les images sources sont regroupées comme référence, puis ajustées à image_ref. target_frame : une image choisie sert de référence pour la transformation vers image_ref, appliquée uniformément à toutes les images (préserve les différences relatives)."
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "Combiner Hooks [2]",
"inputs": {
@@ -5622,22 +5461,6 @@
}
}
},
"JsonExtractString": {
"display_name": "Extraire une chaîne du JSON",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "Utilise le modèle fourni, le conditionnement positif et négatif pour débruiter l'image latente.",
"display_name": "KSampler",
@@ -13932,44 +13755,6 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "Ramène la sortie débruitée vers le latent d'entrée. Plus la valeur est élevée, plus la fidélité à l'entrée est forte. 0 pour désactiver."
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "Seuil sigma en dessous duquel restore_cfg est désactivé."
},
"strength_end": {
"name": "strength_end",
"tooltip": "Contrôle la force à la fin de l'échantillonnage (sigma faible). Interpolé linéairement depuis le début."
},
"strength_start": {
"name": "strength_start",
"tooltip": "Contrôle la force au début de l'échantillonnage (sigma élevé)."
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "SV3D_Conditioning",
"inputs": {
@@ -14976,58 +14761,6 @@
}
}
},
"SoniloTextToMusic": {
"description": "Générez de la musique à partir d'une invite textuelle en utilisant le modèle IA de Sonilo. Laissez la durée à 0 pour que le modèle l'infère à partir de l'invite.",
"display_name": "Sonilo Texte en Musique",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration",
"tooltip": "Durée cible en secondes. Mettez 0 pour laisser le modèle inférer la durée à partir de l'invite. Maximum : 6 minutes."
},
"prompt": {
"name": "prompt",
"tooltip": "Invite textuelle décrivant la musique à générer."
},
"seed": {
"name": "seed",
"tooltip": "Graine pour la reproductibilité. Actuellement ignorée par le service Sonilo mais conservée pour la cohérence du graphe."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "Générez de la musique à partir d'un contenu vidéo en utilisant le modèle IA de Sonilo. Analyse la vidéo et crée une musique correspondante.",
"display_name": "Sonilo Vidéo en Musique",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"prompt": {
"name": "prompt",
"tooltip": "Invite textuelle optionnelle pour guider la génération musicale. Laissez vide pour une qualité optimale - le modèle analysera entièrement le contenu vidéo."
},
"seed": {
"name": "seed",
"tooltip": "Graine pour la reproductibilité. Actuellement ignorée par le service Sonilo mais conservée pour la cohérence du graphe."
},
"video": {
"name": "video",
"tooltip": "Vidéo d'entrée à partir de laquelle générer la musique. Durée maximale : 6 minutes."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "Sépare l'audio en canaux gauche et droit.",
"display_name": "Séparer les canaux audio",
@@ -16292,10 +16025,6 @@
"thinking": {
"name": "réflexion",
"tooltip": "Fonctionner en mode réflexion si le modèle le permet."
},
"use_default_template": {
"name": "utiliser le modèle par défaut",
"tooltip": "Utiliser l'invite/le modèle système intégré si le modèle en possède un."
}
},
"outputs": {
@@ -16347,10 +16076,6 @@
"thinking": {
"name": "réflexion",
"tooltip": "Fonctionner en mode réflexion si le modèle le permet."
},
"use_default_template": {
"name": "utiliser le modèle par défaut",
"tooltip": "Utiliser l'invite/le modèle système intégré si le modèle en possède un."
}
},
"outputs": {

View File

@@ -584,8 +584,6 @@
"publishButton": "ComfyHub へ公開",
"publishFailedDescription": "ワークフローの公開中に問題が発生しました。もう一度お試しください。",
"publishFailedTitle": "公開に失敗しました",
"publishSuccessDescription": "あなたのワークフローがComfyHubで公開されました。",
"publishSuccessTitle": "公開に成功しました",
"removeExampleImage": "サンプル画像を削除",
"selectAThumbnail": "サムネイルを選択",
"shareAs": "次の形式で共有",
@@ -1216,9 +1214,7 @@
"nothingToDelete": "削除するものがありません",
"nothingToDuplicate": "複製するものがありません",
"nothingToRename": "リネームするものがありません",
"off": "オフ",
"ok": "OK",
"on": "オン",
"openManager": "マネージャーを開く",
"openNewIssue": "新しい問題を開く",
"or": "または",
@@ -1645,16 +1641,7 @@
"exportModel": "モデルをエクスポート",
"exportRecording": "録画をエクスポート",
"exportingModel": "モデルをエクスポート中...",
"fitToViewer": "ビューアに合わせる",
"fov": "FOV",
"gizmo": {
"label": "ギズモ",
"reset": "変換をリセット",
"rotate": "回転",
"scale": "スケール",
"toggle": "ギズモ",
"translate": "移動"
},
"hdri": {
"changeFile": "HDRIを変更",
"intensity": "強度",
@@ -2249,7 +2236,6 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2323,7 +2309,6 @@
"stable_cascade": "安定したカスケード",
"string": "文字列",
"style_model": "スタイルモデル",
"supir": "supir",
"text": "テキスト",
"textgen": "textgen",
"training": "トレーニング",
@@ -2510,8 +2495,6 @@
"advancedInputs": "詳細入力",
"bypass": "バイパス",
"color": "ノードカラー",
"editSubgraph": "サブグラフを編集",
"editTitle": "タイトルを編集",
"enterSubgraph": "サブグラフに入る",
"errorHelp": "詳細なヘルプについては、{github} または {support} をご利用ください",
"errorHelpGithub": "GitHub イシューを提出",
@@ -3462,9 +3445,7 @@
"failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}",
"failedToQueue": "キューに追加できませんでした",
"failedToSaveDraft": "ワークフロードラフトの保存に失敗しました",
"failedToSetGizmoMode": "ギズモモードの設定に失敗しました",
"failedToToggleCamera": "カメラの切り替えに失敗しました",
"failedToToggleGizmo": "ギズモの切り替えに失敗しました",
"failedToToggleGrid": "グリッドの切り替えに失敗しました",
"failedToUpdateBackgroundColor": "背景色の更新に失敗しました",
"failedToUpdateBackgroundImage": "背景画像の更新に失敗しました",

View File

@@ -472,137 +472,6 @@
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "Seedance 2.0 を使用して、最初のフレーム画像とオプションの最後のフレーム画像から動画を生成します。",
"display_name": "ByteDance Seedance 2.0 ファースト・ラストフレームから動画生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"first_frame": {
"name": "最初のフレーム",
"tooltip": "動画の最初のフレーム画像です。"
},
"last_frame": {
"name": "最後のフレーム",
"tooltip": "動画の最後のフレーム画像です。"
},
"model": {
"name": "モデル",
"tooltip": "最高品質には Seedance 2.0、速度最適化には Seedance 2.0 Fast を使用します。"
},
"model_duration": {
"name": "継続時間"
},
"model_generate_audio": {
"name": "オーディオ生成"
},
"model_prompt": {
"name": "プロンプト"
},
"model_ratio": {
"name": "アスペクト比"
},
"model_resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。"
},
"watermark": {
"name": "ウォーターマーク",
"tooltip": "動画にウォーターマークを追加するかどうか。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2ReferenceNode": {
"description": "Seedance 2.0 を使い、リファレンス画像・動画・音声から動画を生成・編集・拡張します。マルチモーダルリファレンス、動画編集、動画拡張に対応しています。",
"display_name": "ByteDance Seedance 2.0 リファレンスから動画生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "モデル",
"tooltip": "最高品質には Seedance 2.0、速度最適化には Seedance 2.0 Fast を使用します。"
},
"model_duration": {
"name": "継続時間"
},
"model_generate_audio": {
"name": "オーディオ生成"
},
"model_prompt": {
"name": "プロンプト"
},
"model_ratio": {
"name": "アスペクト比"
},
"model_resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。"
},
"watermark": {
"name": "ウォーターマーク",
"tooltip": "動画にウォーターマークを追加するかどうか。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDance2TextToVideoNode": {
"description": "テキストプロンプトに基づいて Seedance 2.0 モデルで動画を生成します。",
"display_name": "ByteDance Seedance 2.0 テキストから動画生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "モデル",
"tooltip": "最高品質には Seedance 2.0、速度最適化には Seedance 2.0 Fast を使用します。"
},
"model_duration": {
"name": "継続時間"
},
"model_generate_audio": {
"name": "オーディオ生成"
},
"model_prompt": {
"name": "プロンプト"
},
"model_ratio": {
"name": "アスペクト比"
},
"model_resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。"
},
"watermark": {
"name": "ウォーターマーク",
"tooltip": "動画にウォーターマークを追加するかどうか。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "プロンプトと最初・最後のフレームを使用して動画を生成します。",
"display_name": "ByteDance 最初-最後フレームから動画生成",
@@ -1493,36 +1362,6 @@
}
}
},
"ColorTransfer": {
"description": "さまざまなアルゴリズムを使用して、ある画像の色を別の画像に合わせます。",
"display_name": "ColorTransfer",
"inputs": {
"image_ref": {
"name": "image_ref",
"tooltip": "色を合わせる参照画像。指定しない場合は処理をスキップします。"
},
"image_target": {
"name": "image_target",
"tooltip": "色変換を適用する画像。"
},
"method": {
"name": "method"
},
"source_stats": {
"name": "source_stats",
"tooltip": "per_frame各フレームを個別にimage_refに合わせます。uniformすべてのソースフレームの統計をまとめて基準とし、image_refに合わせます。target_frame選択した1フレームを変換の基準として使用し、すべてのフレームに均一に適用します相対的な違いを保持。"
},
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"name": "image",
"tooltip": null
}
}
},
"CombineHooks2": {
"display_name": "フックを組み合わせる [2]",
"inputs": {
@@ -5622,22 +5461,6 @@
}
}
},
"JsonExtractString": {
"display_name": "JSONから文字列を抽出",
"inputs": {
"json_string": {
"name": "json_string"
},
"key": {
"name": "key"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KSampler": {
"description": "提供されたモデル、正の条件付けと負の条件付けを使用して潜在画像のノイズを除去します。",
"display_name": "Kサンプラー",
@@ -13932,44 +13755,6 @@
}
}
},
"SUPIRApply": {
"display_name": "SUPIRApply",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"restore_cfg": {
"name": "restore_cfg",
"tooltip": "イズ除去後の出力を入力のlatentに近づけます。値が高いほど入力への忠実度が強くなります。0で無効化。"
},
"restore_cfg_s_tmin": {
"name": "restore_cfg_s_tmin",
"tooltip": "このシグマ閾値未満ではrestore_cfgが無効になります。"
},
"strength_end": {
"name": "strength_end",
"tooltip": "サンプリング終了時(低シグマ)の強度を制御します。開始値から線形補間されます。"
},
"strength_start": {
"name": "strength_start",
"tooltip": "サンプリング開始時(高シグマ)の強度を制御します。"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SV3D_Conditioning": {
"display_name": "SV3D条件付け",
"inputs": {
@@ -14976,58 +14761,6 @@
}
}
},
"SoniloTextToMusic": {
"description": "SoniloのAIモデルを使ってテキストプロンプトから音楽を生成します。継続時間を0にすると、プロンプトから自動的に推定されます。",
"display_name": "Sonilo テキストから音楽生成",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"duration": {
"name": "duration",
"tooltip": "目標の長さ。0に設定するとプロンプトから自動的に推定されます。最大6分。"
},
"prompt": {
"name": "prompt",
"tooltip": "生成する音楽を説明するテキストプロンプト。"
},
"seed": {
"name": "seed",
"tooltip": "再現性のためのシード値。現在Soniloサービスでは無視されますが、グラフの一貫性のために保持されています。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SoniloVideoToMusic": {
"description": "SoniloのAIモデルを使って動画コンテンツから音楽を生成します。動画を解析し、マッチする音楽を作成します。",
"display_name": "Sonilo 動画から音楽生成",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"prompt": {
"name": "prompt",
"tooltip": "音楽生成をガイドするためのオプションのテキストプロンプト。空欄の場合は最高品質となり、モデルが動画内容を完全に解析します。"
},
"seed": {
"name": "seed",
"tooltip": "再現性のためのシード値。現在Soniloサービスでは無視されますが、グラフの一貫性のために保持されています。"
},
"video": {
"name": "video",
"tooltip": "音楽を生成する入力動画。最大長6分。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SplitAudioChannels": {
"description": "オーディオを左右のチャンネルに分離します。",
"display_name": "オーディオチャンネル分割",
@@ -16292,10 +16025,6 @@
"thinking": {
"name": "思考モード",
"tooltip": "モデルが対応している場合、思考モードで動作します。"
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "モデルに組み込まれているシステムプロンプト/テンプレートを使用します(ある場合)。"
}
},
"outputs": {
@@ -16347,10 +16076,6 @@
"thinking": {
"name": "思考モード",
"tooltip": "モデルが対応している場合、思考モードで動作します。"
},
"use_default_template": {
"name": "use_default_template",
"tooltip": "モデルに組み込まれているシステムプロンプト/テンプレートを使用します(ある場合)。"
}
},
"outputs": {

View File

@@ -584,8 +584,6 @@
"publishButton": "ComfyHub에 게시하기",
"publishFailedDescription": "워크플로우를 게시하는 중에 문제가 발생했습니다. 다시 시도해 주세요.",
"publishFailedTitle": "게시 실패",
"publishSuccessDescription": "워크플로우가 이제 ComfyHub에 공개되었습니다.",
"publishSuccessTitle": "성공적으로 게시됨",
"removeExampleImage": "예시 이미지 제거",
"selectAThumbnail": "썸네일 선택",
"shareAs": "다음으로 공유",
@@ -1216,9 +1214,7 @@
"nothingToDelete": "삭제할 항목 없음",
"nothingToDuplicate": "복제할 항목 없음",
"nothingToRename": "이름을 변경할 항목 없음",
"off": "끄기",
"ok": "확인",
"on": "켜기",
"openManager": "관리자 열기",
"openNewIssue": "새 문제 열기",
"or": "또는",
@@ -1645,16 +1641,7 @@
"exportModel": "모델 내보내기",
"exportRecording": "녹화 내보내기",
"exportingModel": "모델 내보내기 중...",
"fitToViewer": "뷰어에 맞추기",
"fov": "FOV",
"gizmo": {
"label": "Gizmo",
"reset": "변환 초기화",
"rotate": "회전",
"scale": "크기 조절",
"toggle": "Gizmo",
"translate": "이동"
},
"hdri": {
"changeFile": "HDRI 변경",
"intensity": "강도",
@@ -2249,7 +2236,6 @@
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"Sonilo": "Sonilo",
"Sora": "Sora",
"Stability AI": "Stability AI",
"Tencent": "Tencent",
@@ -2323,7 +2309,6 @@
"stable_cascade": "Stable Cascade",
"string": "문자열",
"style_model": "스타일 모델",
"supir": "supir",
"text": "텍스트",
"textgen": "textgen",
"training": "학습",
@@ -2510,8 +2495,6 @@
"advancedInputs": "고급 입력",
"bypass": "우회",
"color": "노드 색상",
"editSubgraph": "서브그래프 편집",
"editTitle": "제목 편집",
"enterSubgraph": "서브그래프 진입",
"errorHelp": "더 많은 도움이 필요하시면 {github} 또는 {support}를 이용하세요.",
"errorHelpGithub": "GitHub 이슈 제출",
@@ -3462,9 +3445,7 @@
"failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}",
"failedToQueue": "대기열 추가 실패",
"failedToSaveDraft": "워크플로우 초안 저장에 실패했습니다",
"failedToSetGizmoMode": "Gizmo 모드 설정에 실패했습니다",
"failedToToggleCamera": "카메라 전환 실패",
"failedToToggleGizmo": "Gizmo 전환에 실패했습니다",
"failedToToggleGrid": "그리드 전환 실패",
"failedToUpdateBackgroundColor": "배경색 업데이트 실패",
"failedToUpdateBackgroundImage": "배경 이미지 업데이트 실패",

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