mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
47 Commits
playwright
...
glary/miss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262d25d1b5 | ||
|
|
7d4f2ad02e | ||
|
|
7162681f11 | ||
|
|
611ec191fb | ||
|
|
f12853ec74 | ||
|
|
3afe7c2e0e | ||
|
|
2742780b17 | ||
|
|
e739665727 | ||
|
|
a6b3aa1667 | ||
|
|
d682b3c7da | ||
|
|
65b8a5652c | ||
|
|
5a598ef2e1 | ||
|
|
2c772077e0 | ||
|
|
00c294297e | ||
|
|
983789753e | ||
|
|
91ed6a37e2 | ||
|
|
15c5a298a6 | ||
|
|
65e27b5cdf | ||
|
|
dd16e7a9ea | ||
|
|
63d0e3ae5d | ||
|
|
71ca582325 | ||
|
|
9ed7a7bd87 | ||
|
|
3e62033f09 | ||
|
|
78630f5485 | ||
|
|
55c5fce522 | ||
|
|
4b5c15fc7d | ||
|
|
b36242475c | ||
|
|
2f4116fa81 | ||
|
|
d83c84aa85 | ||
|
|
c1c3fba1ac | ||
|
|
35bfe509b3 | ||
|
|
5d98e11ba1 | ||
|
|
60c7471818 | ||
|
|
0ac4c3d6c5 | ||
|
|
feafdc0b4a | ||
|
|
2fea0aa538 | ||
|
|
a1ba567dbc | ||
|
|
d2e30645fe | ||
|
|
fc61b19cb9 | ||
|
|
8a5a8f0a6e | ||
|
|
0638e8e993 | ||
|
|
07ce7123c8 | ||
|
|
799ffcf4b6 | ||
|
|
1020e8cf32 | ||
|
|
b157182a20 | ||
|
|
2bfe3443ab | ||
|
|
4c35add5bc |
@@ -1,125 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
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/` |
|
||||
88
.github/actions/resolve-pr-from-workflow-run/action.yaml
vendored
Normal file
88
.github/actions/resolve-pr-from-workflow-run/action.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
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));
|
||||
15
.github/workflows/ci-perf-report.yaml
vendored
15
.github/workflows/ci-perf-report.yaml
vendored
@@ -58,21 +58,6 @@ 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
|
||||
|
||||
7
.github/workflows/ci-size-data.yaml
vendored
7
.github/workflows/ci-size-data.yaml
vendored
@@ -32,13 +32,6 @@ 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:
|
||||
|
||||
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -6,6 +6,10 @@ 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
|
||||
@@ -30,40 +34,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR Number
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr
|
||||
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;
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Test Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.skip != 'true' && 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.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +59,7 @@ jobs:
|
||||
path: reports
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -85,6 +72,6 @@ jobs:
|
||||
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -6,6 +6,10 @@ 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
|
||||
@@ -30,40 +34,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR Number
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr
|
||||
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;
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Storybook Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.skip != 'true' && 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.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +59,7 @@ jobs:
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -82,6 +69,6 @@ jobs:
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -18,6 +18,12 @@ 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:
|
||||
@@ -25,6 +31,8 @@ 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
|
||||
@@ -32,28 +40,83 @@ 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 --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel pull --yes --environment=preview
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
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)
|
||||
|
||||
- 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 --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
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")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
- 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: Publish preview outputs
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
|
||||
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
|
||||
run: |
|
||||
if [[ "$ALIAS_OK" == "true" ]]; then
|
||||
STABLE_URL="https://$ALIAS_HOST"
|
||||
else
|
||||
STABLE_URL="$DEPLOY_URL"
|
||||
fi
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
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"
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -71,19 +134,24 @@ 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 --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
URL=$(vercel deploy --prebuilt --prod)
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
|
||||
37
.github/workflows/pr-report.yaml
vendored
37
.github/workflows/pr-report.yaml
vendored
@@ -30,42 +30,7 @@ jobs:
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr-meta
|
||||
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);
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Find size workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
|
||||
46
.github/workflows/pr-vercel-website-preview.yaml
vendored
46
.github/workflows/pr-vercel-website-preview.yaml
vendored
@@ -12,6 +12,10 @@ 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
|
||||
@@ -31,38 +35,24 @@ jobs:
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
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"
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- 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: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
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
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,7 +59,6 @@ coverage/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser_tests/local/
|
||||
browser_tests/tests/_recording-session.spec.ts
|
||||
|
||||
.env
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-test": {
|
||||
"command": "pnpm",
|
||||
"args": ["dlx", "@playwright/mcp@0.0.68"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
"dist/*",
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"playwright-report/*",
|
||||
"scripts/patch-playwright-agents.js",
|
||||
"src/extensions/core/*",
|
||||
"src/scripts/*",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
@@ -116,12 +115,6 @@
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tools/test-recorder/**/*.ts"],
|
||||
"rules": {
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["browser_tests/**/*.ts"],
|
||||
"jsPlugins": ["eslint-plugin-playwright"],
|
||||
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -44,26 +44,12 @@ 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
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"destination": "https://blog.comfy.org/",
|
||||
"permanent": true
|
||||
},
|
||||
{ "source": "/press", "destination": "/about", "permanent": true }
|
||||
{
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -70,32 +70,6 @@ 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:**
|
||||
|
||||
135
browser_tests/assets/nodes/glsl_shader_in_subgraph.json
Normal file
135
browser_tests/assets/nodes/glsl_shader_in_subgraph.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"id": "ee111111-2222-4333-8444-000000000001",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "aa999999-8888-4777-a666-555555555555",
|
||||
"pos": [400, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"title": "GLSL Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "aa999999-8888-4777-a666-555555555555",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "GLSL Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [50, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 200, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "bb888888-7777-4666-a555-444444444444",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 920, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [250, 180],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
69
browser_tests/assets/nodes/glsl_shader_standalone.json
Normal file
69
browser_tests/assets/nodes/glsl_shader_standalone.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [200, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(v_texCoord.x, v_texCoord.y, 0.5, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
179
browser_tests/assets/nodes/glsl_shader_subgraph_with_float.json
Normal file
179
browser_tests/assets/nodes/glsl_shader_subgraph_with_float.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"id": "ee111111-2222-4333-8444-000000000002",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "aa999999-8888-4777-a666-555555555556",
|
||||
"pos": [400, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"title": "GLSL Subgraph With Float",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "aa999999-8888-4777-a666-555555555556",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "GLSL Subgraph With Float",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [50, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 200, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "bb888888-7777-4666-a555-444444444445",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 920, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 180],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_float0",
|
||||
"localized_name": "floats.u_float0",
|
||||
"name": "floats.u_float0",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "FLOAT",
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveFloat" },
|
||||
"widgets_values": [1.0]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_bool.json
Normal file
105
browser_tests/assets/nodes/glsl_shader_with_bool.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_bool0",
|
||||
"localized_name": "bools.u_bool0",
|
||||
"name": "bools.u_bool0",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform bool u_bool0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = u_bool0 ? vec4(1.0, 0.0, 0.0, 1.0) : vec4(0.0, 0.0, 1.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveBoolean",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "BOOLEAN",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "BOOLEAN",
|
||||
"name": "BOOLEAN",
|
||||
"type": "BOOLEAN",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveBoolean" },
|
||||
"widgets_values": [false]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "BOOLEAN"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_float.json
Normal file
105
browser_tests/assets/nodes/glsl_shader_with_float.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_float0",
|
||||
"localized_name": "floats.u_float0",
|
||||
"name": "floats.u_float0",
|
||||
"shape": 7,
|
||||
"type": "FLOAT",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform float u_float0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(u_float0, v_texCoord.y, 0.5, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 58],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "FLOAT",
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveFloat" },
|
||||
"widgets_values": [0.25]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
105
browser_tests/assets/nodes/glsl_shader_with_int.json
Normal file
105
browser_tests/assets/nodes/glsl_shader_with_int.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "u_int0",
|
||||
"localized_name": "ints.u_int0",
|
||||
"name": "ints.u_int0",
|
||||
"shape": 7,
|
||||
"type": "INT",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform int u_int0;\nuniform vec2 u_resolution;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = vec4(float(u_int0) / 100.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [80, 200],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": { "name": "value" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "PrimitiveInt" },
|
||||
"widgets_values": [25, "randomize"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
92
browser_tests/assets/nodes/glsl_shader_with_loadimage.json
Normal file
92
browser_tests/assets/nodes/glsl_shader_with_loadimage.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1] },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "GLSLShader",
|
||||
"pos": [400, 200],
|
||||
"size": [460, 320],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "image0",
|
||||
"localized_name": "images.image0",
|
||||
"name": "images.image0",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "fragment_shader",
|
||||
"name": "fragment_shader",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "fragment_shader" },
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "size_mode",
|
||||
"name": "size_mode",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": { "name": "size_mode" },
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE0",
|
||||
"name": "IMAGE0",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE1",
|
||||
"name": "IMAGE1",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE2",
|
||||
"name": "IMAGE2",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"localized_name": "IMAGE3",
|
||||
"name": "IMAGE3",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "GLSLShader" },
|
||||
"widgets_values": [
|
||||
"#version 300 es\nprecision highp float;\nuniform sampler2D u_image0;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = texture(u_image0, v_texCoord);\n}\n",
|
||||
"from_input"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -30,8 +30,6 @@ 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'
|
||||
@@ -179,7 +177,6 @@ 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
|
||||
@@ -233,7 +230,6 @@ 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)
|
||||
}
|
||||
@@ -499,7 +495,6 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class ShortcutsTab {
|
||||
readonly essentialsTab: Locator
|
||||
readonly viewControlsTab: Locator
|
||||
@@ -16,6 +18,26 @@ 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
|
||||
@@ -23,6 +45,7 @@ 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')
|
||||
@@ -38,6 +61,15 @@ 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> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class CancelSubscriptionDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
readonly keepSubscriptionButton: Locator
|
||||
readonly confirmCancelButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.heading = this.root.getByRole('heading', {
|
||||
name: 'Cancel subscription'
|
||||
})
|
||||
this.keepSubscriptionButton = this.root.getByRole('button', {
|
||||
name: 'Keep subscription'
|
||||
})
|
||||
this.confirmCancelButton = this.root.getByRole('button', {
|
||||
name: 'Cancel subscription'
|
||||
})
|
||||
}
|
||||
|
||||
async open(cancelAt?: string) {
|
||||
await this.page.evaluate((date) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showCancelSubscriptionDialog(date)
|
||||
}, cancelAt)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
54
browser_tests/fixtures/components/TopUpCreditsDialog.ts
Normal file
54
browser_tests/fixtures/components/TopUpCreditsDialog.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class TopUpCreditsDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
readonly insufficientHeading: Locator
|
||||
readonly preset10: Locator
|
||||
readonly preset25: Locator
|
||||
readonly preset50: Locator
|
||||
readonly preset100: Locator
|
||||
readonly payAmountInput: Locator
|
||||
readonly pricingLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
|
||||
this.insufficientHeading = this.root.getByRole('heading', {
|
||||
name: 'Add more credits to run'
|
||||
})
|
||||
this.preset10 = this.root.getByRole('button', {
|
||||
name: '$10',
|
||||
exact: true
|
||||
})
|
||||
this.preset25 = this.root.getByRole('button', {
|
||||
name: '$25',
|
||||
exact: true
|
||||
})
|
||||
this.preset50 = this.root.getByRole('button', {
|
||||
name: '$50',
|
||||
exact: true
|
||||
})
|
||||
this.preset100 = this.root.getByRole('button', {
|
||||
name: '$100',
|
||||
exact: true
|
||||
})
|
||||
this.payAmountInput = this.root
|
||||
.getByTestId('top-up-pay-amount')
|
||||
.locator('input')
|
||||
this.pricingLink = this.root.getByRole('link', {
|
||||
name: 'View pricing details'
|
||||
})
|
||||
}
|
||||
|
||||
async open(options?: { isInsufficientCredits?: boolean }) {
|
||||
await this.page.evaluate((opts) => {
|
||||
void (
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).dialog.showTopUpCreditsDialog(opts)
|
||||
}, options)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
}
|
||||
@@ -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', '--listen', '0.0.0.0'],
|
||||
argv: ['main.py'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
|
||||
184
browser_tests/fixtures/helpers/HelpCenterHelper.ts
Normal file
184
browser_tests/fixtures/helpers/HelpCenterHelper.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
75
browser_tests/fixtures/helpers/LogsTerminalHelper.ts
Normal file
75
browser_tests/fixtures/helpers/LogsTerminalHelper.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
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'
|
||||
@@ -120,6 +123,27 @@ 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,
|
||||
@@ -202,3 +226,13 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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>
|
||||
}
|
||||
@@ -115,6 +115,13 @@ 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',
|
||||
@@ -199,6 +206,13 @@ 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',
|
||||
@@ -211,34 +225,11 @@ export const TestIds = {
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Helper type for accessing nested TestIds (excludes function values)
|
||||
*/
|
||||
export type TestIdValue =
|
||||
| (typeof TestIds.sidebar)[keyof typeof TestIds.sidebar]
|
||||
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
|
||||
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
|
||||
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
|
||||
| (typeof TestIds.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]
|
||||
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]
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -331,6 +332,22 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Specs
|
||||
|
||||
This is a directory for test plans.
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
@@ -17,6 +18,8 @@ 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 ({
|
||||
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
@@ -87,12 +89,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets respects pagination params', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
@@ -110,12 +112,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets filters by include_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
@@ -129,14 +131,12 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('PUT /assets/:id updates asset in store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -175,14 +175,12 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -193,11 +191,12 @@ 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 }) => {
|
||||
test('POST /assets returns upload response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -217,14 +215,12 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -235,14 +231,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('tracks POST, PUT, DELETE mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -265,12 +261,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET requests are not tracked as mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -280,14 +276,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 }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('returns error status for all asset routes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -296,16 +292,14 @@ 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
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
|
||||
@@ -1,98 +1,151 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} 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('should open bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
test.describe('panel', () => {
|
||||
test.beforeEach(async ({ logsTerminal }) => {
|
||||
await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs([])
|
||||
})
|
||||
|
||||
await expect(bottomPanel.root).toBeHidden()
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
test('should show Logs tab when terminal panel opens', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
test.describe('terminal', () => {
|
||||
test.beforeEach(async ({ logsTerminal }) => {
|
||||
await logsTerminal.mockSubscribeLogs()
|
||||
await logsTerminal.mockRawLogs([])
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('shows loading spinner while logs are loading', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const resolveRaw = await logsTerminal.mockRawLogsPending()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
})
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
|
||||
|
||||
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
resolveRaw()
|
||||
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
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).toBeHidden()
|
||||
})
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
|
||||
test('should switch between shortcuts and terminal panels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
logLine
|
||||
)
|
||||
})
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
test('appends log entries received via WebSocket', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
const ws = await getWebSocket()
|
||||
const firstLine = 'First live log line'
|
||||
const secondLine = 'Second live log line'
|
||||
|
||||
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([firstLine]))
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
firstLine
|
||||
)
|
||||
|
||||
test('should persist Logs tab content in bottom panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
firstLine
|
||||
)
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
secondLine
|
||||
)
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('copy button copies terminal contents to clipboard', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
const logLine = 'Copy me to the clipboard'
|
||||
await logsTerminal.mockRawLogs([logLine])
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
||||
logLine
|
||||
)
|
||||
|
||||
const isAlreadyActive =
|
||||
(await logsTab.getAttribute('aria-selected')) === 'true'
|
||||
if (!isAlreadyActive) {
|
||||
await logsTab.click()
|
||||
}
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const xtermContainer = bottomPanel.root.locator('.xterm')
|
||||
await expect(xtermContainer).toBeVisible()
|
||||
})
|
||||
await comfyPage.bottomPanel.logs.terminalRoot.hover()
|
||||
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
|
||||
await comfyPage.bottomPanel.logs.copyButton.click()
|
||||
|
||||
test('should render xterm container in terminal panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
await expect
|
||||
.poll(() => getClipboardText(comfyPage.page))
|
||||
.toContain(logLine)
|
||||
})
|
||||
|
||||
await bottomPanel.toggleButton.click()
|
||||
await expect(bottomPanel.root).toBeVisible()
|
||||
test('shows error message when raw-logs API fails', async ({
|
||||
comfyPage,
|
||||
logsTerminal
|
||||
}) => {
|
||||
await logsTerminal.mockRawLogsError()
|
||||
|
||||
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
|
||||
await expect(logsTab).toBeVisible()
|
||||
await comfyPage.bottomPanel.toggleLogs()
|
||||
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
44
browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts
Normal file
44
browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
|
||||
|
||||
test.describe('CancelSubscription dialog', { tag: '@ui' }, () => {
|
||||
let dialog: CancelSubscriptionDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new CancelSubscriptionDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test('displays dialog with title and formatted date', async () => {
|
||||
await dialog.open('2025-12-31T12:00:00Z')
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.root).toContainText('December 31, 2025')
|
||||
})
|
||||
|
||||
test('"Keep subscription" button closes dialog', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await dialog.keepSubscriptionButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dialog', async ({ comfyPage }) => {
|
||||
await dialog.open()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('"Cancel subscription" button initiates cancellation flow', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.confirmCancelButton).toBeEnabled()
|
||||
|
||||
await dialog.confirmCancelButton.click()
|
||||
|
||||
// Next state: dialog closes once the cancellation flow completes
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
...mockSystemStats,
|
||||
system: {
|
||||
...mockSystemStats.system,
|
||||
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
|
||||
argv: ['main.py', '--enable-manager']
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
|
||||
58
browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts
Normal file
58
browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
|
||||
|
||||
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
|
||||
let dialog: TopUpCreditsDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
dialog = new TopUpCreditsDialog(comfyPage.page)
|
||||
})
|
||||
|
||||
test('displays dialog with heading and preset amounts', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.heading).toBeVisible()
|
||||
await expect(dialog.preset10).toBeVisible()
|
||||
await expect(dialog.preset25).toBeVisible()
|
||||
await expect(dialog.preset50).toBeVisible()
|
||||
await expect(dialog.preset100).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays insufficient credits message when opened with flag', async () => {
|
||||
await dialog.open({ isInsufficientCredits: true })
|
||||
|
||||
await expect(dialog.insufficientHeading).toBeVisible()
|
||||
await expect(dialog.root).toContainText(
|
||||
"You don't have enough credits to run this workflow"
|
||||
)
|
||||
})
|
||||
|
||||
test('selecting a preset amount updates the pay amount', async () => {
|
||||
await dialog.open()
|
||||
|
||||
// Default preset is $50, click $10 instead
|
||||
await dialog.preset10.click()
|
||||
|
||||
await expect(dialog.payAmountInput).toHaveValue('10')
|
||||
})
|
||||
|
||||
test('close button dismisses dialog', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await dialog.closeButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('pricing details link points to docs pricing page', async () => {
|
||||
await dialog.open()
|
||||
|
||||
await expect(dialog.pricingLink).toBeVisible()
|
||||
await expect(dialog.pricingLink).toHaveAttribute(
|
||||
'href',
|
||||
/partner-nodes\/pricing/
|
||||
)
|
||||
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
174
browser_tests/tests/helpCenter.spec.ts
Normal file
174
browser_tests/tests/helpCenter.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
81
browser_tests/tests/topbarMenuCommands.spec.ts
Normal file
81
browser_tests/tests/topbarMenuCommands.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
512
browser_tests/tests/vueNodes/glslPreview.spec.ts
Normal file
512
browser_tests/tests/vueNodes/glslPreview.spec.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const GLSL_NODE_ID = '1'
|
||||
const GLSL_NODE_TITLE = 'GLSL Shader'
|
||||
const PRIMITIVE_FLOAT_NODE_TITLE = 'Float'
|
||||
const PRIMITIVE_INT_NODE_TITLE = 'Int'
|
||||
const PRIMITIVE_BOOLEAN_NODE_TITLE = 'Boolean'
|
||||
|
||||
const RED_SHADER = [
|
||||
'#version 300 es',
|
||||
'precision highp float;',
|
||||
'uniform vec2 u_resolution;',
|
||||
'in vec2 v_texCoord;',
|
||||
'layout(location = 0) out vec4 fragColor0;',
|
||||
'void main() {',
|
||||
' fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);',
|
||||
'}'
|
||||
].join('\n')
|
||||
|
||||
/** Shader that drives every pixel's red channel from `u_float0` alone. */
|
||||
const FLOAT_RED_SHADER = [
|
||||
'#version 300 es',
|
||||
'precision highp float;',
|
||||
'uniform float u_float0;',
|
||||
'layout(location = 0) out vec4 fragColor0;',
|
||||
'void main() {',
|
||||
' fragColor0 = vec4(u_float0, 0.0, 0.0, 1.0);',
|
||||
'}'
|
||||
].join('\n')
|
||||
|
||||
/** Wait until an `<img>` locator has finished decoding. */
|
||||
async function waitForImageDecoded(image: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
image.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
/** Page-object helper bound to the GLSLShader node under test. */
|
||||
class GLSLShaderNode {
|
||||
readonly node: Locator
|
||||
/**
|
||||
* Any `<img>` inside the node whose src is a `blob:` URL. Covers both
|
||||
* the standalone-node `LivePreview` path and the subgraph-wrapped
|
||||
* promoted-preview path (where the blob surfaces via `ImagePreview`).
|
||||
*/
|
||||
readonly previewImage: Locator
|
||||
readonly shaderTextbox: Locator
|
||||
readonly widthInput: Locator
|
||||
readonly heightInput: Locator
|
||||
|
||||
constructor(
|
||||
private readonly comfyPage: ComfyPage,
|
||||
readonly nodeId: string,
|
||||
readonly title: string
|
||||
) {
|
||||
this.node = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
this.previewImage = this.node.locator('img[src^="blob:"]')
|
||||
this.shaderTextbox = this.node.getByRole('textbox', {
|
||||
name: 'fragment_shader'
|
||||
})
|
||||
this.widthInput = this.node
|
||||
.getByLabel('size_mode.width', { exact: true })
|
||||
.locator('input')
|
||||
this.heightInput = this.node
|
||||
.getByLabel('size_mode.height', { exact: true })
|
||||
.locator('input')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `execution_start` + `executed` with an image output for this node,
|
||||
* which satisfies the `hasExecutionOutput` gate in `useGLSLPreview`.
|
||||
*/
|
||||
async simulateExecutionOutput(ws: WebSocketRoute) {
|
||||
const exec = new ExecutionHelper(this.comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await this.comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
exec.executed(jobId, this.nodeId, {
|
||||
images: [{ filename: 'glsl_test.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
exec.executionSuccess(jobId)
|
||||
}
|
||||
|
||||
async getPreviewSrc(): Promise<string | null> {
|
||||
return this.previewImage.getAttribute('src')
|
||||
}
|
||||
|
||||
async getPreviewNaturalSize(): Promise<{ width: number; height: number }> {
|
||||
return this.previewImage.evaluate((el: HTMLImageElement) => ({
|
||||
width: el.naturalWidth,
|
||||
height: el.naturalHeight
|
||||
}))
|
||||
}
|
||||
|
||||
async selectSizeMode(option: 'from_input' | 'custom'): Promise<void> {
|
||||
await this.comfyPage.vueNodes.selectComboOption(
|
||||
this.title,
|
||||
'size_mode',
|
||||
option
|
||||
)
|
||||
}
|
||||
|
||||
/** Wait until the preview image has a blob: URL and return it. */
|
||||
async waitForBlobSrc(): Promise<string> {
|
||||
await expect.poll(() => this.getPreviewSrc()).toMatch(/^blob:/)
|
||||
return (await this.getPreviewSrc())!
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the preview blob to a 2D canvas and verify every pixel matches.
|
||||
*/
|
||||
async expectEveryPixelToBe(
|
||||
expected: [number, number, number, number],
|
||||
tolerance = 1
|
||||
): Promise<void> {
|
||||
await waitForImageDecoded(this.previewImage)
|
||||
const mismatch = await this.previewImage.evaluate(
|
||||
(
|
||||
img: HTMLImageElement,
|
||||
args: { exp: [number, number, number, number]; tol: number }
|
||||
) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
if (Math.abs(data[i + c] - args.exp[c]) > args.tol) {
|
||||
return {
|
||||
index: i / 4,
|
||||
actual: [data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
{ exp: expected, tol: tolerance }
|
||||
)
|
||||
const message = mismatch
|
||||
? `expected every pixel ≈ [${expected.join(',')}] ±${tolerance}; pixel ${mismatch.index} was [${mismatch.actual.join(',')}]`
|
||||
: undefined
|
||||
expect(mismatch, message).toBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an image file onto a LoadImage node and wait for its preview to render.
|
||||
*/
|
||||
async function dropImageOntoLoadImage(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
const node = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
const box = await node.boundingBox()
|
||||
expect(
|
||||
box,
|
||||
`LoadImage node ${nodeId} must have a bounding box`
|
||||
).not.toBeNull()
|
||||
await comfyPage.dragDrop.dragAndDropFile(filename, {
|
||||
dropPosition: { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
})
|
||||
const preview = node.locator('.image-preview img')
|
||||
await expect(preview).toBeVisible()
|
||||
await waitForImageDecoded(preview)
|
||||
}
|
||||
|
||||
test.describe('GLSL Shader Preview', { tag: ['@vue-nodes', '@node'] }, () => {
|
||||
test.describe('standalone node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_standalone')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('renders a blob preview into the node after execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await test.step('no preview is present before execution', async () => {
|
||||
await expect(glsl.previewImage).toHaveCount(0)
|
||||
})
|
||||
|
||||
await test.step('execution populates preview with a blob URL', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
|
||||
await expect(glsl.previewImage).toBeVisible()
|
||||
await glsl.waitForBlobSrc()
|
||||
})
|
||||
})
|
||||
|
||||
test('refreshes the preview when the fragment shader is edited', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await test.step('editing the shader replaces the blob URL', async () => {
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test('custom size_mode controls rendered resolution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await test.step('switch size_mode to custom and set width/height', async () => {
|
||||
await glsl.selectSizeMode('custom')
|
||||
|
||||
await expect(glsl.widthInput).toBeVisible()
|
||||
await expect(glsl.heightInput).toBeVisible()
|
||||
|
||||
await glsl.widthInput.fill('16')
|
||||
await glsl.widthInput.blur()
|
||||
await glsl.heightInput.fill('32')
|
||||
await glsl.heightInput.blur()
|
||||
})
|
||||
|
||||
await test.step('executed preview uses the custom resolution', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
|
||||
await expect(glsl.previewImage).toBeVisible()
|
||||
await glsl.waitForBlobSrc()
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 16, height: 32 })
|
||||
})
|
||||
})
|
||||
|
||||
test('logs a compile failure then recovers when shader becomes valid again', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
// Captures every `[GLSL] shader compilation failed` warning emitted
|
||||
// by `useGLSLPreview.ts` during this test.
|
||||
const compileFailure = comfyPage.page.waitForEvent('console', {
|
||||
predicate: (msg) =>
|
||||
msg.type() === 'warning' &&
|
||||
msg.text().includes('[GLSL] shader compilation failed')
|
||||
})
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const goodSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await glsl.shaderTextbox.fill('not valid glsl at all')
|
||||
await compileFailure // ensures the invalid shader actually hit the compiler
|
||||
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(goodSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive float source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_float')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('refreshes preview when upstream PrimitiveFloat value changes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const floatValueWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
PRIMITIVE_FLOAT_NODE_TITLE,
|
||||
'value'
|
||||
)
|
||||
const { input: floatValueInput } =
|
||||
comfyPage.vueNodes.getInputNumberControls(floatValueWidget)
|
||||
|
||||
// Drive every pixel's red channel directly from u_float0 so the
|
||||
// before/after refresh is visually obvious (dim red → pure red).
|
||||
await glsl.shaderTextbox.fill(FLOAT_RED_SHADER)
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
// Workflow default is 0.25 → ~64; RGBA16F → PNG round-trip can drift.
|
||||
await glsl.expectEveryPixelToBe([64, 0, 0, 255], 2)
|
||||
|
||||
await test.step('changing the upstream float value re-renders the preview', async () => {
|
||||
await expect(floatValueInput).toBeVisible()
|
||||
await floatValueInput.fill('1.0')
|
||||
await floatValueInput.blur()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with upstream LoadImage', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_loadimage')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
const LOAD_IMAGE_NODE_ID = '2'
|
||||
|
||||
test('uses upstream image dimensions', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await dropImageOntoLoadImage(
|
||||
comfyPage,
|
||||
LOAD_IMAGE_NODE_ID,
|
||||
'image64x64.webp'
|
||||
)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 64, height: 64 })
|
||||
})
|
||||
|
||||
test('ensures shaders are correctly executed', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
|
||||
await dropImageOntoLoadImage(
|
||||
comfyPage,
|
||||
LOAD_IMAGE_NODE_ID,
|
||||
'image64x64.webp'
|
||||
)
|
||||
await glsl.shaderTextbox.fill(RED_SHADER)
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
|
||||
await expect
|
||||
.poll(() => glsl.getPreviewNaturalSize())
|
||||
.toEqual({ width: 64, height: 64 })
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive int source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_int')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('refreshes preview when upstream PrimitiveInt value changes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const intValueWidget = comfyPage.vueNodes.getWidgetByName(
|
||||
PRIMITIVE_INT_NODE_TITLE,
|
||||
'value'
|
||||
)
|
||||
const { input: intValueInput } =
|
||||
comfyPage.vueNodes.getInputNumberControls(intValueWidget)
|
||||
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
const initialSrc = await glsl.waitForBlobSrc()
|
||||
|
||||
await test.step('changing the upstream int value re-renders the preview', async () => {
|
||||
await expect(intValueInput).toBeVisible()
|
||||
await intValueInput.fill('100')
|
||||
await intValueInput.blur()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(initialSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
})
|
||||
|
||||
await test.step('upstream int value flows through as the u_int0 uniform', async () => {
|
||||
// Shader writes vec4(float(u_int0) / 100.0, 0, 0, 1); value 100 → red.
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with primitive boolean source', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_with_bool')
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
})
|
||||
|
||||
test('upstream PrimitiveBoolean value flows through as the u_bool0 uniform', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
|
||||
const booleanToggle = comfyPage.vueNodes
|
||||
.getNodeByTitle(PRIMITIVE_BOOLEAN_NODE_TITLE)
|
||||
.getByRole('switch', { name: 'value' })
|
||||
|
||||
await test.step('boolean=false renders blue', async () => {
|
||||
await glsl.simulateExecutionOutput(ws)
|
||||
await glsl.waitForBlobSrc()
|
||||
// Blue (non-max channel) through RGBA16F → PNG round-trip can drift by 2.
|
||||
await glsl.expectEveryPixelToBe([0, 0, 255, 255], 2)
|
||||
})
|
||||
|
||||
await test.step('toggling boolean=true re-renders red', async () => {
|
||||
const blueSrc = (await glsl.getPreviewSrc())!
|
||||
await expect(booleanToggle).toBeVisible()
|
||||
await booleanToggle.click()
|
||||
|
||||
await expect.poll(() => glsl.getPreviewSrc()).not.toBe(blueSrc)
|
||||
await expect.poll(() => glsl.getPreviewSrc()).toMatch(/^blob:/)
|
||||
await glsl.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GLSL inside a subgraph', () => {
|
||||
const SUBGRAPH_NODE_ID = '1'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_in_subgraph')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('renders a GLSL blob preview on the outer subgraph node', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
// Inside a subgraph, the GLSL renderer writes the blob preview to the
|
||||
// INNER GLSLShader's locator; the outer subgraph node surfaces it via
|
||||
// the promoted-preview path (ImagePreview component), not LivePreview.
|
||||
// Either way, the observable signal is an <img> with a blob: src.
|
||||
const subgraph = new GLSLShaderNode(
|
||||
comfyPage,
|
||||
SUBGRAPH_NODE_ID,
|
||||
'GLSL Subgraph'
|
||||
)
|
||||
|
||||
await subgraph.simulateExecutionOutput(ws)
|
||||
await expect(subgraph.previewImage).toBeVisible()
|
||||
await subgraph.waitForBlobSrc()
|
||||
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GLSL inside a subgraph with uniform source', () => {
|
||||
const SUBGRAPH_NODE_ID = '1'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'nodes/glsl_shader_subgraph_with_float'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('extracts uniform sources from inner upstream widgets', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
// Inner PrimitiveFloat is wired to the inner GLSLShader's floats.u_float0
|
||||
// input. useGLSLUniforms.extractUniformSources should pick it up and feed
|
||||
// 1.0 as u_float0 — shader outputs vec4(u_float0, 0, 0, 1) → red.
|
||||
const subgraph = new GLSLShaderNode(
|
||||
comfyPage,
|
||||
SUBGRAPH_NODE_ID,
|
||||
'GLSL Subgraph With Float'
|
||||
)
|
||||
|
||||
await subgraph.simulateExecutionOutput(ws)
|
||||
await expect(subgraph.previewImage).toBeVisible()
|
||||
await subgraph.waitForBlobSrc()
|
||||
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
@@ -26,6 +26,8 @@ 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.
|
||||
@@ -172,7 +174,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.
|
||||
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).
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
@@ -182,6 +184,8 @@ System design is deferred to a future ADR.
|
||||
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:
|
||||
@@ -231,6 +235,23 @@ 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.
|
||||
|
||||
@@ -31,9 +31,6 @@ const config: KnipConfig = {
|
||||
},
|
||||
'apps/website': {
|
||||
entry: ['src/scripts/**/*.ts']
|
||||
},
|
||||
'tools/test-recorder': {
|
||||
project: ['src/**/*.ts']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.4",
|
||||
"version": "1.44.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -43,7 +43,6 @@
|
||||
"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",
|
||||
|
||||
@@ -28,6 +28,7 @@ export type {
|
||||
BillingPlansResponse,
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -44,11 +45,6 @@ export type {
|
||||
CheckHubUsernameErrors,
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClaimInviteCodeData,
|
||||
ClaimInviteCodeError,
|
||||
ClaimInviteCodeErrors,
|
||||
ClaimInviteCodeResponse,
|
||||
ClaimInviteCodeResponses,
|
||||
ClientOptions,
|
||||
CreateAssetDownloadData,
|
||||
CreateAssetDownloadError,
|
||||
@@ -113,6 +109,13 @@ export type {
|
||||
CreateWorkflowVersionRequest,
|
||||
CreateWorkflowVersionResponse,
|
||||
CreateWorkflowVersionResponses,
|
||||
CreateWorkspaceApiKeyData,
|
||||
CreateWorkspaceApiKeyError,
|
||||
CreateWorkspaceApiKeyErrors,
|
||||
CreateWorkspaceApiKeyRequest,
|
||||
CreateWorkspaceApiKeyResponse,
|
||||
CreateWorkspaceApiKeyResponse2,
|
||||
CreateWorkspaceApiKeyResponses,
|
||||
CreateWorkspaceData,
|
||||
CreateWorkspaceError,
|
||||
CreateWorkspaceErrors,
|
||||
@@ -237,12 +240,16 @@ export type {
|
||||
GetBillingStatusErrors,
|
||||
GetBillingStatusResponse,
|
||||
GetBillingStatusResponses,
|
||||
GetCustomNodeProxyData,
|
||||
GetCustomNodeProxyErrors,
|
||||
GetCustomNodeProxyResponses,
|
||||
GetDeletionRequestData,
|
||||
GetDeletionRequestError,
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponse,
|
||||
GetExtensionsResponses,
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
@@ -263,7 +270,9 @@ export type {
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHealthData,
|
||||
GetHealthError,
|
||||
GetHealthErrors,
|
||||
GetHealthResponse,
|
||||
GetHealthResponses,
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
@@ -285,11 +294,6 @@ export type {
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetInviteCodeStatusData,
|
||||
GetInviteCodeStatusError,
|
||||
GetInviteCodeStatusErrors,
|
||||
GetInviteCodeStatusResponse,
|
||||
GetInviteCodeStatusResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
@@ -339,9 +343,19 @@ export type {
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetNodeByIdData,
|
||||
GetNodeByIdErrors,
|
||||
GetNodeByIdResponses,
|
||||
GetNodeInfoData,
|
||||
GetNodeInfoResponse,
|
||||
GetNodeInfoResponses,
|
||||
GetNodeInfoSchemaData,
|
||||
GetNodeInfoSchemaResponses,
|
||||
GetNodeReplacementsData,
|
||||
GetNodeReplacementsError,
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetPaymentPortalData,
|
||||
@@ -422,11 +436,15 @@ export type {
|
||||
GetUserErrors,
|
||||
GetUserResponse,
|
||||
GetUserResponses,
|
||||
GetUsersRawData,
|
||||
GetUsersRawErrors,
|
||||
GetUsersRawResponses,
|
||||
GetUsersInfoData,
|
||||
GetUsersInfoError,
|
||||
GetUsersInfoErrors,
|
||||
GetUsersInfoResponse,
|
||||
GetUsersInfoResponses,
|
||||
GetVhsQueryVideoData,
|
||||
GetVhsQueryVideoError,
|
||||
GetVhsQueryVideoErrors,
|
||||
GetVhsQueryVideoResponse,
|
||||
GetVhsQueryVideoResponses,
|
||||
GetVhsViewAudioData,
|
||||
GetVhsViewAudioErrors,
|
||||
@@ -487,8 +505,6 @@ export type {
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
@@ -551,6 +567,12 @@ export type {
|
||||
ListWorkflowsErrors,
|
||||
ListWorkflowsResponse,
|
||||
ListWorkflowsResponses,
|
||||
ListWorkspaceApiKeysData,
|
||||
ListWorkspaceApiKeysError,
|
||||
ListWorkspaceApiKeysErrors,
|
||||
ListWorkspaceApiKeysResponse,
|
||||
ListWorkspaceApiKeysResponse2,
|
||||
ListWorkspaceApiKeysResponses,
|
||||
ListWorkspaceInvitesData,
|
||||
ListWorkspaceInvitesError,
|
||||
ListWorkspaceInvitesErrors,
|
||||
@@ -601,6 +623,9 @@ export type {
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PostCustomNodeProxyData,
|
||||
PostCustomNodeProxyErrors,
|
||||
PostCustomNodeProxyResponses,
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
@@ -661,6 +686,11 @@ export type {
|
||||
ResubscribeResponse,
|
||||
ResubscribeResponse2,
|
||||
ResubscribeResponses,
|
||||
RevokeWorkspaceApiKeyData,
|
||||
RevokeWorkspaceApiKeyError,
|
||||
RevokeWorkspaceApiKeyErrors,
|
||||
RevokeWorkspaceApiKeyResponse,
|
||||
RevokeWorkspaceApiKeyResponses,
|
||||
RevokeWorkspaceInviteData,
|
||||
RevokeWorkspaceInviteError,
|
||||
RevokeWorkspaceInviteErrors,
|
||||
@@ -668,13 +698,6 @@ export type {
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretResponse,
|
||||
SendUserInviteEmailData,
|
||||
SendUserInviteEmailError,
|
||||
SendUserInviteEmailErrors,
|
||||
SendUserInviteEmailRequest,
|
||||
SendUserInviteEmailResponse,
|
||||
SendUserInviteEmailResponse2,
|
||||
SendUserInviteEmailResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
@@ -718,6 +741,12 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateHubWorkflowData,
|
||||
UpdateHubWorkflowError,
|
||||
UpdateHubWorkflowErrors,
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -734,6 +763,11 @@ export type {
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
UpdateSubscriptionCacheResponse,
|
||||
UpdateSubscriptionCacheResponses,
|
||||
UpdateWorkflowData,
|
||||
UpdateWorkflowError,
|
||||
UpdateWorkflowErrors,
|
||||
@@ -765,6 +799,13 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
VerifyApiKeyRequest,
|
||||
VerifyApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyData,
|
||||
VerifyWorkspaceApiKeyError,
|
||||
VerifyWorkspaceApiKeyErrors,
|
||||
VerifyWorkspaceApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyResponses,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
@@ -779,6 +820,7 @@ export type {
|
||||
WorkflowVersionContentResponse,
|
||||
WorkflowVersionResponse,
|
||||
Workspace,
|
||||
WorkspaceApiKeyInfo,
|
||||
WorkspaceSummary,
|
||||
WorkspaceWithRole
|
||||
} from './types.gen'
|
||||
|
||||
884
packages/ingest-types/src/types.gen.ts
generated
884
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
353
packages/ingest-types/src/zod.gen.ts
generated
353
packages/ingest-types/src/zod.gen.ts
generated
@@ -20,6 +20,32 @@ 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(),
|
||||
@@ -134,8 +160,43 @@ export const zHubWorkflowTemplateEntry = z.object({
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().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(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
@@ -641,6 +702,53 @@ 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()
|
||||
@@ -979,22 +1087,6 @@ 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'])
|
||||
@@ -1005,22 +1097,6 @@ 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
|
||||
*/
|
||||
@@ -1040,6 +1116,7 @@ export const zCreateSessionResponse = z.object({
|
||||
* User information response
|
||||
*/
|
||||
export const zUserResponse = z.object({
|
||||
id: z.string(),
|
||||
status: z.string()
|
||||
})
|
||||
|
||||
@@ -1194,8 +1271,16 @@ export const zQueueManageRequest = z.object({
|
||||
* Queue information with pending and running jobs
|
||||
*/
|
||||
export const zQueueInfo = z.object({
|
||||
queue_running: z.array(z.array(z.unknown())).optional(),
|
||||
queue_pending: z.array(z.array(z.unknown())).optional()
|
||||
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()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1315,6 +1400,10 @@ 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()
|
||||
@@ -1427,6 +1516,17 @@ 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(),
|
||||
@@ -1588,7 +1688,7 @@ export const zViewFileData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - File content returned (used when channel or res parameter is present)
|
||||
* Processed PNG image with extracted channel
|
||||
*/
|
||||
export const zViewFileResponse = z.string()
|
||||
|
||||
@@ -2429,6 +2529,56 @@ 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(),
|
||||
@@ -2440,43 +2590,6 @@ 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(),
|
||||
@@ -2488,6 +2601,19 @@ 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(),
|
||||
@@ -2527,6 +2653,23 @@ 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({
|
||||
@@ -2991,6 +3134,25 @@ 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(),
|
||||
@@ -3019,12 +3181,32 @@ export const zGetVhsQueryVideoData = z.object({
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetUsersRawData = 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({
|
||||
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(),
|
||||
@@ -3065,6 +3247,11 @@ 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(),
|
||||
@@ -3134,3 +3321,19 @@ 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()
|
||||
})
|
||||
|
||||
252
packages/registry-types/src/comfyRegistryTypes.ts
generated
252
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -2907,7 +2907,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/generate instead. */
|
||||
/** Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/poll instead. */
|
||||
post: operations["veoPoll"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -10482,77 +10482,196 @@ export interface components {
|
||||
};
|
||||
VeoGenVidRequest: {
|
||||
instances?: {
|
||||
/** @description Text description of the video */
|
||||
/** @description Text description of the video to generate */
|
||||
prompt: string;
|
||||
/** @description Optional image to guide video generation */
|
||||
/** @description Optional first frame image to guide video generation */
|
||||
image?: {
|
||||
/** Format: byte */
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
mimeType?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/** @description Optional last frame image to guide video generation */
|
||||
/** @description Optional last frame image. Used with image to generate video between first and last frames. Supported by Veo 3.0+ models. */
|
||||
lastFrame?: {
|
||||
/** Format: byte */
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/** @description Optional reference images to guide video generation. Supports up to 3 asset images or 1 style image. Supported by Veo 3.1 models (preview). */
|
||||
referenceImages?: {
|
||||
image: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded image data
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the image */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the image (image/jpeg or image/png)
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "image/jpeg" | "image/png";
|
||||
} & (unknown | unknown);
|
||||
/**
|
||||
* @description Type of reference image
|
||||
* @enum {string}
|
||||
*/
|
||||
referenceType: "asset" | "style";
|
||||
/** @description Optional identifier for the reference image */
|
||||
referenceId?: string;
|
||||
}[];
|
||||
/** @description Optional input video for video extension or editing. Incompatible with image and referenceImages. */
|
||||
video?: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded video bytes
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI of the input video */
|
||||
gcsUri?: string;
|
||||
/**
|
||||
* @description MIME type of the video
|
||||
* @enum {string}
|
||||
*/
|
||||
mimeType?: "video/mov" | "video/mpeg" | "video/mp4" | "video/mpg" | "video/avi" | "video/wmv" | "video/mpegps" | "video/x-flv";
|
||||
} & (unknown | unknown);
|
||||
/**
|
||||
* @description Camera motion type. Requires image to be provided.
|
||||
* @enum {string}
|
||||
*/
|
||||
cameraControl?: "fixed" | "pan_left" | "pan_right" | "tilt_up" | "tilt_down" | "truck_left" | "truck_right" | "pedestal_up" | "pedestal_down" | "push_in" | "pull_out";
|
||||
/** @description Optional mask for video editing. Applies to input video. */
|
||||
mask?: {
|
||||
/**
|
||||
* Format: byte
|
||||
* @description Base64-encoded mask bytes
|
||||
*/
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Cloud Storage URI to mask file */
|
||||
gcsUri?: string;
|
||||
/** @description MIME type of the mask (image/png, image/jpeg, image/webp, or video formats) */
|
||||
mimeType?: string;
|
||||
/**
|
||||
* @description How the mask is applied
|
||||
* @enum {string}
|
||||
*/
|
||||
maskMode?: "insert" | "remove" | "remove_static" | "outpaint";
|
||||
} & (unknown | unknown);
|
||||
}[];
|
||||
parameters?: {
|
||||
/** @example 16:9 */
|
||||
aspectRatio?: string;
|
||||
/**
|
||||
* @description Aspect ratio of the generated video. Default: 16:9
|
||||
* @example 16:9
|
||||
* @enum {string}
|
||||
*/
|
||||
aspectRatio?: "16:9" | "9:16";
|
||||
/** @description Text describing what to avoid in the generated video */
|
||||
negativePrompt?: string;
|
||||
/** @enum {string} */
|
||||
personGeneration?: "ALLOW" | "BLOCK";
|
||||
/**
|
||||
* @description Controls people in generated videos. Default: allow_adult
|
||||
* @enum {string}
|
||||
*/
|
||||
personGeneration?: "dont_allow" | "allow_adult" | "allowAll";
|
||||
/** @description Number of videos to generate. If not specified, 1 video is generated. */
|
||||
sampleCount?: number;
|
||||
/** Format: uint32 */
|
||||
/**
|
||||
* Format: uint32
|
||||
* @description Random seed for deterministic output. Different seeds used per video if sampleCount > 1.
|
||||
*/
|
||||
seed?: number;
|
||||
/** @description Optional Cloud Storage URI to upload the video */
|
||||
/** @description Cloud Storage URI (gs://) for saving generated videos */
|
||||
storageUri?: string;
|
||||
/** @description Target duration of the generated video in seconds. Veo 2: 5-8. Veo 3/3.1: 4, 6, or 8. Default: 8 */
|
||||
durationSeconds?: number;
|
||||
/** @description Frame rate of generated videos in frames per second */
|
||||
fps?: number;
|
||||
/** @description Automatically improve prompt for higher quality. Defaults to true. */
|
||||
enhancePrompt?: boolean;
|
||||
/** @description Generate audio for the video. Only supported by veo 3 models. */
|
||||
/** @description Whether to generate audio along with the video. Defaults to true. Supported by Veo 3.0+ models. */
|
||||
generateAudio?: boolean;
|
||||
/**
|
||||
* @description Output video resolution. Supported by Veo 3.0+ models. Default: 720p
|
||||
* @enum {string}
|
||||
*/
|
||||
resolution?: "720p" | "1080p" | "4k";
|
||||
/**
|
||||
* @description Resize approach for input image. Default: pad
|
||||
* @enum {string}
|
||||
*/
|
||||
resizeMode?: "pad" | "crop";
|
||||
/**
|
||||
* @description Video compression quality. Default: optimized
|
||||
* @enum {string}
|
||||
*/
|
||||
compressionQuality?: "optimized" | "lossless";
|
||||
/**
|
||||
* @description Operation type for the video generation request
|
||||
* @enum {string}
|
||||
*/
|
||||
task?: "textToVideo" | "imageToVideo" | "referenceToVideo" | "edit" | "extend" | "upscale";
|
||||
/** @description Cloud Pub/Sub topic for progress updates (projects/{project}/topics/{topic}) */
|
||||
pubsubTopic?: string;
|
||||
};
|
||||
};
|
||||
/** @description Response from a Veo video generation request. Contains the operation name for polling. */
|
||||
VeoGenVidResponse: {
|
||||
/**
|
||||
* @description Operation resource name
|
||||
* @description Operation resource name used to poll for results via fetchPredictOperation
|
||||
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
VeoGenVidPollRequest: {
|
||||
/**
|
||||
* @description Full operation name (from predict response)
|
||||
* @description Full operation name returned from the generate response
|
||||
* @example projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID
|
||||
*/
|
||||
operationName: string;
|
||||
};
|
||||
/** @description Response from polling a Veo video generation operation */
|
||||
VeoGenVidPollResponse: {
|
||||
/** @description Operation resource name */
|
||||
name?: string;
|
||||
/** @description Whether the operation has completed */
|
||||
done?: boolean;
|
||||
/** @description The actual prediction response if done is true */
|
||||
/** @description The prediction response, present when done is true */
|
||||
response?: {
|
||||
/** @example type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse */
|
||||
"@type"?: string;
|
||||
/** @description Count of media filtered by responsible AI policies */
|
||||
/** @description Number of videos filtered by responsible AI policies */
|
||||
raiMediaFilteredCount?: number;
|
||||
/** @description Reasons why media was filtered by responsible AI policies */
|
||||
/** @description Reasons why videos were filtered by responsible AI policies */
|
||||
raiMediaFilteredReasons?: string[];
|
||||
videos?: {
|
||||
/** @description Cloud Storage URI of the video */
|
||||
/** @description Cloud Storage URI of the generated video */
|
||||
gcsUri?: string;
|
||||
/** @description Base64-encoded video content */
|
||||
bytesBase64Encoded?: string;
|
||||
/** @description Video MIME type */
|
||||
/** @description Video MIME type (video/mp4) */
|
||||
mimeType?: string;
|
||||
}[];
|
||||
};
|
||||
/** @description Error details if operation failed */
|
||||
/** @description Error details, present if the operation failed */
|
||||
error?: {
|
||||
/** @description Error code */
|
||||
/** @description gRPC error code */
|
||||
code?: number;
|
||||
/** @description Error message */
|
||||
message?: string;
|
||||
@@ -10653,8 +10772,8 @@ export interface components {
|
||||
};
|
||||
OpenAIImageGenerationRequest: {
|
||||
/**
|
||||
* @description The model to use for image generation
|
||||
* @example dall-e-3
|
||||
* @description The model to use for image generation (e.g., dall-e-2, dall-e-3, gpt-image-1, gpt-image-1.5, gpt-image-2)
|
||||
* @example gpt-image-2
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
@@ -10721,8 +10840,8 @@ export interface components {
|
||||
};
|
||||
OpenAIImageEditRequest: {
|
||||
/**
|
||||
* @description The model to use for image editing
|
||||
* @example gpt-image-1
|
||||
* @description The model to use for image editing (e.g., dall-e-2, gpt-image-1, gpt-image-1.5, gpt-image-2)
|
||||
* @example gpt-image-2
|
||||
*/
|
||||
model: string;
|
||||
/**
|
||||
@@ -15951,40 +16070,35 @@ export interface components {
|
||||
QuiverTextToSVGRequest: {
|
||||
/**
|
||||
* @description Model identifier for SVG generation
|
||||
* @default arrow-preview
|
||||
* @example arrow-1.1
|
||||
*/
|
||||
model: string;
|
||||
/** @description Text description of the desired SVG output */
|
||||
prompt: string;
|
||||
/** @description Additional style or formatting guidance */
|
||||
instructions?: string;
|
||||
/** @description Up to 4 reference images (URL or base64) */
|
||||
references?: components["schemas"]["QuiverImageObject"][];
|
||||
/** @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 Number of SVGs to generate
|
||||
* @default 1
|
||||
*/
|
||||
n: number;
|
||||
/**
|
||||
* @description Enable Server-Sent Events streaming
|
||||
* @default false
|
||||
*/
|
||||
stream: boolean;
|
||||
/**
|
||||
* @description Randomness control
|
||||
* @description Sampling temperature
|
||||
* @default 1
|
||||
*/
|
||||
temperature: number;
|
||||
/**
|
||||
* @description Nucleus sampling parameter
|
||||
* @description Nucleus sampling probability
|
||||
* @default 1
|
||||
*/
|
||||
top_p: number;
|
||||
/**
|
||||
* @description Token presence penalty
|
||||
* @description Penalty for tokens already present in prior output
|
||||
* @default 0
|
||||
*/
|
||||
presence_penalty: number;
|
||||
presence_penalty: number | null;
|
||||
/** @description Maximum number of output tokens */
|
||||
max_output_tokens?: number;
|
||||
};
|
||||
@@ -15992,42 +16106,37 @@ export interface components {
|
||||
QuiverImageToSVGRequest: {
|
||||
/**
|
||||
* @description Model identifier for SVG vectorization
|
||||
* @default arrow-preview
|
||||
* @example arrow-1.1
|
||||
*/
|
||||
model: string;
|
||||
image: components["schemas"]["QuiverImageObject"];
|
||||
/**
|
||||
* @description Automatically crop to dominant subject
|
||||
* @description Auto-crop image to the dominant subject before vectorization
|
||||
* @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 Randomness control
|
||||
* @description Sampling temperature
|
||||
* @default 1
|
||||
*/
|
||||
temperature: number;
|
||||
/**
|
||||
* @description Nucleus sampling parameter
|
||||
* @description Nucleus sampling probability
|
||||
* @default 1
|
||||
*/
|
||||
top_p: number;
|
||||
/**
|
||||
* @description Token presence penalty
|
||||
* @description Penalty for tokens already present in prior output
|
||||
* @default 0
|
||||
*/
|
||||
presence_penalty: number;
|
||||
presence_penalty: number | null;
|
||||
/** @description Maximum number of output tokens */
|
||||
max_output_tokens?: number;
|
||||
};
|
||||
@@ -16044,24 +16153,39 @@ 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 Generated SVG content */
|
||||
svg?: string;
|
||||
created: number;
|
||||
data: {
|
||||
/** @description Raw SVG markup */
|
||||
svg: string;
|
||||
/**
|
||||
* @description MIME type of the output
|
||||
* @default image/svg+xml
|
||||
* @enum {string}
|
||||
*/
|
||||
mime_type: string;
|
||||
mime_type: "image/svg+xml";
|
||||
}[];
|
||||
/** @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?: {
|
||||
/** @description Total tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
total_tokens?: number;
|
||||
/** @description Input tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
input_tokens?: number;
|
||||
/** @description Output tokens used */
|
||||
/**
|
||||
* @deprecated
|
||||
* @description Deprecated. Token counts are retained for compatibility and may be zeroed.
|
||||
*/
|
||||
output_tokens?: number;
|
||||
};
|
||||
};
|
||||
@@ -27164,8 +27288,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the model to use for generation */
|
||||
modelId: string;
|
||||
/** @description The Veo model ID to use for generation */
|
||||
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -27219,8 +27343,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the model to use for generation */
|
||||
modelId: string;
|
||||
/** @description The Veo model ID */
|
||||
modelId: "veo-2.0-generate-001" | "veo-3.0-generate-001" | "veo-3.0-fast-generate-001" | "veo-3.1-generate-001" | "veo-3.1-fast-generate-001" | "veo-3.1-lite-generate-001";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
@@ -357,4 +358,12 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -361,9 +361,17 @@ 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
97
pnpm-lock.yaml
generated
@@ -251,7 +251,7 @@ catalogs:
|
||||
version: 16.5.0
|
||||
gsap:
|
||||
specifier: ^3.14.2
|
||||
version: 3.15.0
|
||||
version: 3.14.2
|
||||
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.23
|
||||
version: 1.3.21
|
||||
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.10
|
||||
version: 2.12.9
|
||||
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.10
|
||||
version: 2.12.9
|
||||
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.15.0
|
||||
version: 3.14.2
|
||||
lenis:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
|
||||
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -1029,22 +1029,6 @@ 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':
|
||||
@@ -1739,12 +1723,6 @@ 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==}
|
||||
|
||||
@@ -5691,8 +5669,8 @@ packages:
|
||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
|
||||
console-grid@2.2.4:
|
||||
resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==}
|
||||
console-grid@2.2.3:
|
||||
resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==}
|
||||
|
||||
constantinople@4.0.1:
|
||||
resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
|
||||
@@ -6543,10 +6521,6 @@ 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==}
|
||||
|
||||
@@ -6724,8 +6698,8 @@ packages:
|
||||
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
||||
gsap@3.15.0:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
gsap@3.14.2:
|
||||
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
|
||||
|
||||
h3@1.15.10:
|
||||
resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==}
|
||||
@@ -7338,8 +7312,8 @@ packages:
|
||||
resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
lenis@1.3.23:
|
||||
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
|
||||
lenis@1.3.21:
|
||||
resolution: {integrity: sha512-RXWTYm7KQE4Kv8ezxL6wvK0Oiv7aRr6FDo+eNaaniTeu7pLdHokqMIJ5CXO4x5ezvd+9ONdpSFkprLpXsVWmEw==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
react: '>=17.0.0'
|
||||
@@ -7610,8 +7584,8 @@ packages:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
lz-utils@2.1.1:
|
||||
resolution: {integrity: sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==}
|
||||
lz-utils@2.1.0:
|
||||
resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
@@ -7889,12 +7863,12 @@ packages:
|
||||
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
monocart-coverage-reports@2.12.10:
|
||||
resolution: {integrity: sha512-veL2la1QlqS4aPn1m5X+AjPtyK5SP72p/mypf7qRC2Yy0E/ba3gzOFYFMAcAp5Y5pgmSlkTa2SCfrOgUqPmc7g==}
|
||||
monocart-coverage-reports@2.12.9:
|
||||
resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==}
|
||||
hasBin: true
|
||||
|
||||
monocart-locator@1.0.3:
|
||||
resolution: {integrity: sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==}
|
||||
monocart-locator@1.0.2:
|
||||
resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
@@ -11214,17 +11188,6 @@ 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': {}
|
||||
@@ -15293,7 +15256,7 @@ snapshots:
|
||||
|
||||
consola@3.4.2: {}
|
||||
|
||||
console-grid@2.2.4: {}
|
||||
console-grid@2.2.3: {}
|
||||
|
||||
constantinople@4.0.1:
|
||||
dependencies:
|
||||
@@ -16335,10 +16298,6 @@ 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:
|
||||
@@ -16538,7 +16497,7 @@ snapshots:
|
||||
section-matter: 1.0.0
|
||||
strip-bom-string: 1.0.0
|
||||
|
||||
gsap@3.15.0: {}
|
||||
gsap@3.14.2: {}
|
||||
|
||||
h3@1.15.10:
|
||||
dependencies:
|
||||
@@ -17229,7 +17188,7 @@ snapshots:
|
||||
dependencies:
|
||||
package-json: 10.0.1
|
||||
|
||||
lenis@1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
|
||||
lenis@1.3.21(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)
|
||||
@@ -17438,7 +17397,7 @@ snapshots:
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
lz-utils@2.1.1: {}
|
||||
lz-utils@2.1.0: {}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
@@ -17912,22 +17871,22 @@ snapshots:
|
||||
|
||||
modern-tar@0.7.3: {}
|
||||
|
||||
monocart-coverage-reports@2.12.10:
|
||||
monocart-coverage-reports@2.12.9:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-loose: 8.5.2
|
||||
acorn-walk: 8.3.5
|
||||
commander: 14.0.3
|
||||
console-grid: 2.2.4
|
||||
console-grid: 2.2.3
|
||||
eight-colors: 1.3.3
|
||||
foreground-child: 4.0.3
|
||||
foreground-child: 3.3.1
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
lz-utils: 2.1.1
|
||||
monocart-locator: 1.0.3
|
||||
lz-utils: 2.1.0
|
||||
monocart-locator: 1.0.2
|
||||
|
||||
monocart-locator@1.0.3: {}
|
||||
monocart-locator@1.0.2: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
packages:
|
||||
- apps/**
|
||||
- packages/**
|
||||
- tools/**
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/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(', ')}`
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/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/"
|
||||
10
src/base/common/selectionBounds.ts
Normal file
10
src/base/common/selectionBounds.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** 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
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
|
||||
<div
|
||||
ref="rootEl"
|
||||
data-testid="terminal-root"
|
||||
class="relative size-full overflow-hidden bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal size-full rounded-none p-2">
|
||||
<div ref="terminalEl" class="terminal-host h-full" />
|
||||
<div
|
||||
ref="terminalEl"
|
||||
data-testid="terminal-host"
|
||||
class="terminal-host h-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: tooltipText,
|
||||
showDelay: 300
|
||||
}"
|
||||
data-testid="terminal-copy-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:class="
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div class="size-full bg-transparent">
|
||||
<p v-if="errorMessage" class="p-4 text-center">
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
data-testid="terminal-error-message"
|
||||
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" />
|
||||
|
||||
133
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
133
src/components/boundingbox/WidgetBoundingBox.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/* 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
@@ -52,7 +53,7 @@
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex flex-1 flex-col gap-3" data-testid="top-up-pay-amount">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
|
||||
116
src/components/graph/widgets/MultiSelectWidget.test.ts
Normal file
116
src/components/graph/widgets/MultiSelectWidget.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/components/graph/widgets/TextPreviewWidget.test.ts
Normal file
206
src/components/graph/widgets/TextPreviewWidget.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/* 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('<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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -25,7 +26,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const isParentNodeExecuting = computed(() => {
|
||||
if (executionStore.isIdle) return false
|
||||
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
|
||||
return executionStore.executingNodeIds.includes(parentNodeId)
|
||||
})
|
||||
const formattedText = computed(() => {
|
||||
const src = modelValue.value
|
||||
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
|
||||
@@ -51,39 +56,27 @@ const formattedText = computed(() => {
|
||||
: safeLabel
|
||||
})
|
||||
|
||||
return html
|
||||
// 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']
|
||||
})
|
||||
})
|
||||
|
||||
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 = props.nodeId ?? parentNodeId
|
||||
})
|
||||
|
||||
// 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]
|
||||
}
|
||||
// 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]
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
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)"
|
||||
@@ -103,6 +104,7 @@
|
||||
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)"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
data-testid="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
@@ -38,6 +39,7 @@
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
data-testid="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
246
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
246
src/components/imagecrop/WidgetImageCrop.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/* 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
185
src/components/range/WidgetRange.test.ts
Normal file
185
src/components/range/WidgetRange.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/* 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
staticUrls: {
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
|
||||
githubIssues: 'https://github.com/Comfy-Org/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/comfyanonymous/ComfyUI/issues?q='),
|
||||
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
159
src/components/rightSidePanel/errors/useErrorActions.test.ts
Normal file
159
src/components/rightSidePanel/errors/useErrorActions.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
392
src/components/rightSidePanel/errors/useErrorReport.test.ts
Normal file
392
src/components/rightSidePanel/errors/useErrorReport.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
<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 ? '•' : ''"
|
||||
|
||||
@@ -3,83 +3,15 @@
|
||||
<div>
|
||||
{{ getDownloadLabel(download.savePath ?? '') }}
|
||||
</div>
|
||||
<div v-if="['cancelled', 'error'].includes(download.status ?? '')">
|
||||
<Chip
|
||||
class="mt-2 h-6 bg-red-700 text-sm font-light"
|
||||
removable
|
||||
@remove="handleRemoveDownload"
|
||||
>
|
||||
{{ t('electronFileDownload.cancelled') }}
|
||||
</Chip>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['in_progress', 'paused', 'completed'].includes(download.status ?? '')
|
||||
"
|
||||
class="mt-2 flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="Number(((download.progress ?? 0) * 100).toFixed(1))"
|
||||
:show-value="(download.progress ?? 0) > 0.1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="download.status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.pause')"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="icon-[lucide--pause] size-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="download.status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="icon-[lucide--play] size-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="['in_progress', 'paused'].includes(download.status ?? '')"
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<ElectronDownloadProgress :download />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Chip from 'primevue/chip'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ElectronDownloadProgress from '@/platform/electronDownload/components/ElectronDownloadProgress.vue'
|
||||
import type { ElectronDownload } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import type { ElectronDownload } from '@/stores/electronDownloadStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
|
||||
const props = defineProps<{
|
||||
const { download } = defineProps<{
|
||||
download: ElectronDownload
|
||||
}>()
|
||||
|
||||
@@ -90,19 +22,4 @@ const getDownloadLabel = (savePath: string) => {
|
||||
const dir = parts.pop()
|
||||
return `${dir}/${name}`
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () =>
|
||||
electronDownloadStore.cancel(props.download.url)
|
||||
const triggerPauseDownload = () =>
|
||||
electronDownloadStore.pause(props.download.url)
|
||||
const triggerResumeDownload = () =>
|
||||
electronDownloadStore.resume(props.download.url)
|
||||
|
||||
const handleRemoveDownload = () => {
|
||||
electronDownloadStore.$patch((state) => {
|
||||
state.downloads = state.downloads.filter(
|
||||
({ url }) => url !== props.download.url
|
||||
)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
import DownloadItem from './DownloadItem.vue'
|
||||
|
||||
|
||||
@@ -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 } from 'vue'
|
||||
import { h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
@@ -103,11 +103,13 @@ 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: { value: true },
|
||||
subscriptionTierName: { value: 'Creator' },
|
||||
subscriptionTier: { value: 'CREATOR' },
|
||||
isActiveSubscription: ref(true),
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
}))
|
||||
}))
|
||||
@@ -188,6 +190,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
@@ -406,14 +409,43 @@ 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('hides credits section', () => {
|
||||
it('still shows credits balance', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
|
||||
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('upgrade-to-add-credits-button')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -424,11 +456,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides partner nodes menu item', () => {
|
||||
it('still shows partner nodes menu item', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('partner-nodes-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides plans & pricing menu item', () => {
|
||||
@@ -438,11 +468,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides manage plan menu item', () => {
|
||||
it('still shows manage plan menu item', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('manage-plan-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows user settings menu item', () => {
|
||||
|
||||
@@ -29,11 +29,8 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section (cloud only) -->
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<!-- Credits Section -->
|
||||
<div v-if="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"
|
||||
@@ -49,7 +46,7 @@
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@@ -82,7 +79,7 @@
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="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"
|
||||
@@ -112,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="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"
|
||||
|
||||
243
src/composables/node/useNodeDragAndDrop.test.ts
Normal file
243
src/composables/node/useNodeDragAndDrop.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type DragHandler = (e: DragEvent) => boolean
|
||||
@@ -43,9 +44,10 @@ export const useNodeDragAndDrop = <T>(
|
||||
return !!e?.dataTransfer?.getData('text/uri-list')
|
||||
}
|
||||
|
||||
node.onDragOver = isDraggingFiles
|
||||
const installedDragOver = isDraggingFiles
|
||||
node.onDragOver = installedDragOver
|
||||
|
||||
node.onDragDrop = async function (e: DragEvent) {
|
||||
const installedDragDrop = async function (e: DragEvent) {
|
||||
if (!isDraggingValidFiles(e)) return false
|
||||
|
||||
const files = filterFiles(e.dataTransfer!.files)
|
||||
@@ -73,4 +75,10 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
175
src/composables/node/useNodeFileInput.test.ts
Normal file
175
src/composables/node/useNodeFileInput.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -25,10 +25,12 @@ export function useNodeFileInput(node: LGraphNode, options: FileInputOptions) {
|
||||
fileInput.multiple = allow_batch
|
||||
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput?.files?.length) {
|
||||
const files = Array.from(fileInput.files).filter(fileFilter)
|
||||
if (files.length) onSelect(files)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
|
||||
107
src/composables/node/useNodePaste.test.ts
Normal file
107
src/composables/node/useNodePaste.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type PasteHandler<T> = (files: File[]) => Promise<T>
|
||||
@@ -17,7 +18,7 @@ export const useNodePaste = <T>(
|
||||
) => {
|
||||
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
||||
|
||||
node.pasteFiles = function (files: File[]) {
|
||||
const installedPasteFiles = function (files: File[]) {
|
||||
const filteredFiles = Array.from(files).filter(fileFilter)
|
||||
if (!filteredFiles.length) return false
|
||||
|
||||
@@ -26,4 +27,9 @@ export const useNodePaste = <T>(
|
||||
void onPaste(paste)
|
||||
return true
|
||||
}
|
||||
node.pasteFiles = installedPasteFiles
|
||||
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
if (node.pasteFiles === installedPasteFiles) node.pasteFiles = undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { markRaw } from 'vue'
|
||||
|
||||
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
|
||||
@@ -43,11 +43,9 @@ describe('useExternalLink', () => {
|
||||
|
||||
// Static URLs
|
||||
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
|
||||
expect(staticUrls.github).toBe(
|
||||
'https://github.com/comfyanonymous/ComfyUI'
|
||||
)
|
||||
expect(staticUrls.github).toBe('https://github.com/Comfy-Org/ComfyUI')
|
||||
expect(staticUrls.githubIssues).toBe(
|
||||
'https://github.com/comfyanonymous/ComfyUI/issues'
|
||||
'https://github.com/Comfy-Org/ComfyUI/issues'
|
||||
)
|
||||
expect(staticUrls.githubFrontend).toBe(
|
||||
'https://github.com/Comfy-Org/ComfyUI_frontend'
|
||||
|
||||
@@ -85,8 +85,8 @@ export function useExternalLink() {
|
||||
const staticUrls = {
|
||||
// Static external URLs
|
||||
discord: 'https://www.comfy.org/discord',
|
||||
github: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues',
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
|
||||
@@ -321,6 +321,39 @@ 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -133,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
|
||||
load3d?.refreshViewport()
|
||||
|
||||
load3d?.updateStatusMouseOnNode(true)
|
||||
}
|
||||
})
|
||||
|
||||
node.onMouseLeave = function () {
|
||||
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
|
||||
load3d?.updateStatusMouseOnNode(false)
|
||||
}
|
||||
})
|
||||
|
||||
node.onResize = function () {
|
||||
node.onResize = useChainCallback(node.onResize, () => {
|
||||
load3d?.handleResize()
|
||||
}
|
||||
})
|
||||
|
||||
node.onDrawBackground = function () {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
node.onDrawBackground = useChainCallback(
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
node.onRemoved = function () {
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
}
|
||||
})
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
|
||||
@@ -323,6 +323,10 @@ 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)
|
||||
}
|
||||
)
|
||||
|
||||
699
src/extensions/core/load3d/SceneModelManager.test.ts
Normal file
699
src/extensions/core/load3d/SceneModelManager.test.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
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'
|
||||
@@ -16,7 +17,7 @@ function drawSelectionBorder(
|
||||
if (selectedItems.size <= 1) return
|
||||
|
||||
// Use the same bounds calculation as the toolbox
|
||||
const bounds = createBounds(selectedItems, 10)
|
||||
const bounds = createBounds(selectedItems, SELECTION_BOUNDS_PADDING)
|
||||
if (!bounds) return
|
||||
|
||||
const [x, y, width, height] = bounds
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Point,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
@@ -653,4 +654,47 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user