Compare commits

...

32 Commits

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

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

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

3. Codegen transform skill (.claude/skills/codegen-transform/)
   - Transform rules: @playwright/test → comfyPageFixture, page → comfyPage,
     remove goto, canvas locators, waitForTimeout → nextFrame
   - Structural transforms: wrap in describe with tags, add afterEach cleanup
   - Fixture API reference and before/after examples
2026-04-18 21:45:45 -07:00
Christian Byrne
a3893a593d refactor: move select components from input/ to ui/ component library (#11378)
*PR Created by the Glary-Bot Agent*

---

## Summary

Reconciles `src/components/input/` (older select components) into
`src/components/ui/` (internal component library), eliminating the
separate `input/` directory entirely.

## Changes

- **Move MultiSelect** →
`src/components/ui/multi-select/MultiSelect.vue`
- **Move SingleSelect** →
`src/components/ui/single-select/SingleSelect.vue`
- **Extract shared resources** → `src/components/ui/select/types.ts`
(SelectOption type) and `src/components/ui/select/select.variants.ts`
(CVA styling variants)
- **Update 7 consuming files** to use new import paths
- **Update 1 test file** (AssetFilterBar.test.ts mock paths)
- **Move stories and tests** alongside their components
- **Delete `src/components/input/`** directory

## Context

The `input/` directory contained only MultiSelect and SingleSelect — two
well-built components that already used the same stack as `ui/` (Reka
UI, CVA, Tailwind 4, Composition API). MultiSelect even imported
`ui/button/Button.vue`. Moving them into `ui/` removes the split and
consolidates all reusable components in one place.

No API changes — all component props, slots, events, and behavior are
preserved exactly.

## Verification

- `pnpm typecheck` 
- `pnpm build` 
- `pnpm lint` (stylelint + oxlint + eslint) 
- All 15 relevant tests pass (MultiSelect: 5, SingleSelect: 2,
AssetFilterBar: 8) 
- `pnpm knip` — no dead exports 
- No stale `@/components/input/` references remain 
- Pre-commit hooks pass 
- Git detected all moves as renames (97-100% similarity)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11378-refactor-move-select-components-from-input-to-ui-component-library-3476d73d3650810e99b4c3e0842e67f3)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 20:00:34 -07:00
Terry Jia
deba72e7a0 gizmo controls (#11274)
## Summary
Add Gizmo transform controls to load3d

- Remove automatic model normalization (scale + center) on load; models
now appear at their original transform. The previous auto-normalization
conflicted with gizmo controls — applying scale/position on load made it
impossible to track and reset the user's intentional transform edits vs.
the system's normalization
- Add a manual Fit to Viewer button that performs the same normalization
on demand, giving users explicit control
- Add Gizmo Controls (translate/rotate) for interactive model
manipulation with full state persistence across node properties, viewer
dialog, and model reloads
- Gizmo transform state is excluded from scene capture and recording to
keep outputs clean

## Motivation
The gizmo system is a prerequisite for these potential features:
- Custom cameras — user-placed cameras in the scene need transform
gizmos for precise positioning and orientation
- Custom lights — scene lighting setup requires the ability to
interactively position and aim light sources
- Multi-object scene composition — positioning multiple models relative
to each other requires per-object transform controls
- Pose editor — skeletal pose editing depends on the same transform
infrastructure to manipulate individual bones/joints

Auto-normalization was removed because it silently mutated model
transforms on load, making it impossible to distinguish between the
original model pose and user edits. This broke gizmo reset (which needs
to know the "clean" state) and would corrupt round-trip transform
persistence.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558)
by [Unito](https://www.unito.io)
2026-04-18 22:45:06 -04:00
Rizumu Ayaka
3db0eac353 perf: textarea widget layer composition (#10804)
## Summary

I noticed that nodes using textarea for user input, which contain long
user-entered text, require scrolling within a single node.

Having 40 such textarea nodes in a test canvas is enough to cause lag
(20fps). In contrast, a control group using regular nodes can handle up
to 500 nodes without lag (60fps).

the numerous scrolling text widgets in test workflows are the main
source of performance pressure. Each scrolling text input box imposes
independent layout and layering pressure.

I initially tried more complex solutions to fix this issue, like virtual
scrolling. However, I found that a simple CSS modification was
sufficient and effective. Even when I quadrupled the problematic number
of nodes on my M5 MacBook Air, it remained smooth.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10804-perf-textarea-widget-layer-composition-3356d73d3650814da75adec266d7cad9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-19 02:29:00 +00:00
91 changed files with 5178 additions and 417 deletions

View File

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

8
.mcp.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,10 +34,35 @@ export class Load3DHelper {
return this.node.getByText(name, { exact: true })
}
get gizmoToggleButton(): Locator {
return this.node.getByRole('button', { name: 'Gizmo' })
}
get gizmoTranslateButton(): Locator {
return this.node.getByRole('button', { name: 'Translate' })
}
get gizmoRotateButton(): Locator {
return this.node.getByRole('button', { name: 'Rotate' })
}
get gizmoScaleButton(): Locator {
return this.node.getByRole('button', { name: 'Scale' })
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset Transform' })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()
}
async setBackgroundColor(hex: string): Promise<void> {
await this.colorInput.evaluate((el, value) => {
;(el as HTMLInputElement).value = value

View File

@@ -0,0 +1,87 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
const getGizmoConfig = (page: Page) =>
page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const modelConfig = n?.properties?.['Model Config'] as
| { gizmo?: { enabled: boolean; mode: string } }
| undefined
return modelConfig?.gizmo
})
test.describe('Load3D Gizmo Controls', () => {
test(
'Gizmo category appears in the controls menu',
{ tag: '@smoke' },
async ({ load3d }) => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
}
)
test(
'Selecting Gizmo category shows the toggle button',
{ tag: '@smoke' },
async ({ load3d }) => {
await load3d.openGizmoCategory()
await expect(load3d.gizmoToggleButton).toBeVisible()
await expect(load3d.gizmoTranslateButton).toBeHidden()
await expect(load3d.gizmoRotateButton).toBeHidden()
await expect(load3d.gizmoScaleButton).toBeHidden()
await expect(load3d.gizmoResetButton).toBeHidden()
}
)
test(
'Toggling gizmo reveals mode buttons and updates node state',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await load3d.openGizmoCategory()
await load3d.gizmoToggleButton.click()
await expect(load3d.gizmoTranslateButton).toBeVisible()
await expect(load3d.gizmoRotateButton).toBeVisible()
await expect(load3d.gizmoScaleButton).toBeVisible()
await expect(load3d.gizmoResetButton).toBeVisible()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled))
.toBe(true)
await load3d.gizmoToggleButton.click()
await expect(load3d.gizmoTranslateButton).toBeHidden()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled))
.toBe(false)
}
)
test(
'Selecting a gizmo mode updates node state',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await load3d.openGizmoCategory()
await load3d.gizmoToggleButton.click()
await load3d.gizmoRotateButton.click()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
.toBe('rotate')
await load3d.gizmoScaleButton.click()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
.toBe('scale')
await load3d.gizmoTranslateButton.click()
await expect
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
.toBe('translate')
}
)
})

View File

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

View File

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

View File

@@ -43,6 +43,7 @@
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"comfy-test": "tsx tools/test-recorder/src/index.ts",
"test:browser": "pnpm exec nx e2e",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",

97
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -405,8 +405,8 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'

View File

@@ -28,6 +28,9 @@
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@toggle-gizmo="handleToggleGizmo"
@set-gizmo-mode="handleSetGizmoMode"
@reset-gizmo-transform="handleResetGizmoTransform"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -40,9 +43,27 @@
@seek="handleSeek"
/>
</div>
<div class="pointer-events-auto absolute top-12 right-2 z-20">
<div class="flex flex-col rounded-lg bg-backdrop/30">
<Button
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.fitToViewer')"
@click="handleFitToViewer"
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
</div>
</div>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-12 right-2 z-20"
class="pointer-events-auto absolute top-24 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
@@ -51,8 +72,8 @@
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-12': !enable3DViewer,
'top-24': enable3DViewer
'top-24': !enable3DViewer,
'top-36': enable3DViewer
}"
>
<RecordingControls
@@ -77,6 +98,7 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Button from '@/components/ui/button/Button.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -143,6 +165,10 @@ const {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -92,6 +92,14 @@
v-if="showExportControls"
@export-model="handleExportModel"
/>
<GizmoControls
v-if="showGizmoControls"
v-model:gizmo-config="modelConfig!.gizmo"
@toggle-gizmo="handleToggleGizmo"
@set-gizmo-mode="handleSetGizmoMode"
@reset-gizmo-transform="handleResetGizmoTransform"
/>
</div>
</div>
</template>
@@ -102,6 +110,7 @@ import { computed, ref } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
@@ -109,6 +118,7 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue'
import Button from '@/components/ui/button/Button.vue'
import type {
CameraConfig,
GizmoMode,
LightConfig,
ModelConfig,
SceneConfig
@@ -148,6 +158,7 @@ const categoryLabels: Record<string, string> = {
model: 'load3d.model',
camera: 'load3d.camera',
light: 'load3d.light',
gizmo: 'load3d.gizmo.label',
export: 'load3d.export'
}
@@ -156,7 +167,7 @@ const availableCategories = computed(() => {
return ['scene', 'model', 'camera']
}
return ['scene', 'model', 'camera', 'light', 'export']
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
})
const showSceneControls = computed(
@@ -175,6 +186,9 @@ const showLightControls = computed(
!!modelConfig.value
)
const showExportControls = computed(() => activeCategory.value === 'export')
const showGizmoControls = computed(
() => activeCategory.value === 'gizmo' && !!modelConfig.value
)
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
@@ -190,6 +204,7 @@ const categoryIcons = {
model: 'icon-[lucide--box]',
camera: 'icon-[lucide--camera]',
light: 'icon-[lucide--sun]',
gizmo: 'icon-[lucide--move-3d]',
export: 'icon-[lucide--download]'
} as const
@@ -205,6 +220,9 @@ const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'updateHdriFile', file: File | null): void
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const handleBackgroundImageUpdate = (file: File | null) => {
@@ -218,4 +236,16 @@ const handleExportModel = (format: string) => {
const handleHDRIFileUpdate = (file: File | null) => {
emit('updateHdriFile', file)
}
const handleToggleGizmo = (enabled: boolean) => {
emit('toggleGizmo', enabled)
}
const handleSetGizmoMode = (mode: GizmoMode) => {
emit('setGizmoMode', mode)
}
const handleResetGizmoTransform = () => {
emit('resetGizmoTransform')
}
</script>

View File

@@ -74,6 +74,14 @@
/>
</div>
<div class="space-y-4 p-2">
<GizmoControls
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
v-model:gizmo-mode="viewer.gizmoMode.value"
@reset-transform="viewer.resetGizmoTransform"
/>
</div>
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
</div>
@@ -99,6 +107,7 @@ import { useI18n } from 'vue-i18n'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
import GizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'

View File

@@ -0,0 +1,155 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
import type { GizmoConfig } from '@/extensions/core/load3d/interfaces'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
gizmo: {
toggle: 'Gizmo',
translate: 'Translate',
rotate: 'Rotate',
scale: 'Scale',
reset: 'Reset Transform'
}
}
}
}
})
function makeConfig(overrides: Partial<GizmoConfig> = {}): GizmoConfig {
return {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 },
...overrides
}
}
function renderComponent(initial: Partial<GizmoConfig> = {}) {
const gizmoConfig = ref<GizmoConfig>(makeConfig(initial))
const utils = render(GizmoControls, {
props: {
gizmoConfig: gizmoConfig.value,
'onUpdate:gizmoConfig': (v: GizmoConfig | undefined) => {
if (v) gizmoConfig.value = v
}
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...utils, gizmoConfig, user: userEvent.setup() }
}
describe('GizmoControls', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders only the toggle button when gizmo is disabled', () => {
renderComponent({ enabled: false })
expect(screen.getByRole('button', { name: 'Gizmo' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Rotate' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Scale' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Reset Transform' })).toBeNull()
})
it('renders mode and reset buttons when gizmo is enabled', () => {
renderComponent({ enabled: true })
expect(screen.getByRole('button', { name: 'Translate' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Rotate' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Scale' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Reset Transform' })).toBeTruthy()
})
it('flips enabled and emits toggleGizmo when the toggle is clicked', async () => {
const { user, gizmoConfig, emitted } = renderComponent({ enabled: false })
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(gizmoConfig.value.enabled).toBe(true)
expect(emitted().toggleGizmo).toEqual([[true]])
})
it('turns off gizmo and emits false when toggled from enabled state', async () => {
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(gizmoConfig.value.enabled).toBe(false)
expect(emitted().toggleGizmo).toEqual([[false]])
})
it.each([
['Translate', 'translate'],
['Rotate', 'rotate'],
['Scale', 'scale']
] as const)(
'sets mode to %s and emits setGizmoMode when clicked',
async (label, mode) => {
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
await user.click(screen.getByRole('button', { name: label }))
expect(gizmoConfig.value.mode).toBe(mode)
expect(emitted().setGizmoMode).toEqual([[mode]])
}
)
it('emits resetGizmoTransform without mutating config on reset click', async () => {
const { user, gizmoConfig, emitted } = renderComponent({
enabled: true,
mode: 'rotate'
})
await user.click(screen.getByRole('button', { name: 'Reset Transform' }))
expect(emitted().resetGizmoTransform).toEqual([[]])
expect(gizmoConfig.value.mode).toBe('rotate')
expect(gizmoConfig.value.enabled).toBe(true)
})
it('highlights the active mode button with a ring', () => {
renderComponent({ enabled: true, mode: 'rotate' })
const translate = screen.getByRole('button', { name: 'Translate' })
const rotate = screen.getByRole('button', { name: 'Rotate' })
const scale = screen.getByRole('button', { name: 'Scale' })
expect(rotate.className).toContain('ring-2')
expect(translate.className).not.toContain('ring-2')
expect(scale.className).not.toContain('ring-2')
})
it('does nothing when clicked with no model value bound', async () => {
const user = userEvent.setup()
const { emitted } = render(GizmoControls, {
props: { gizmoConfig: undefined },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
expect(emitted().toggleGizmo).toBeUndefined()
})
})

View File

@@ -0,0 +1,122 @@
<template>
<div class="flex flex-col">
<Button
v-tooltip.right="{ value: t('load3d.gizmo.toggle'), showDelay: 300 }"
variant="textonly"
size="icon"
:class="cn('rounded-full', gizmoEnabled && 'ring-2 ring-white/50')"
:aria-label="t('load3d.gizmo.toggle')"
@click="toggleGizmo"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
<template v-if="gizmoEnabled">
<Button
v-tooltip.right="{
value: t('load3d.gizmo.translate'),
showDelay: 300
}"
variant="textonly"
size="icon"
:class="
cn(
'rounded-full',
gizmoMode === 'translate' && 'ring-2 ring-white/50'
)
"
:aria-label="t('load3d.gizmo.translate')"
@click="setMode('translate')"
>
<i class="pi pi-arrows-alt text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: t('load3d.gizmo.rotate'),
showDelay: 300
}"
variant="textonly"
size="icon"
:class="
cn('rounded-full', gizmoMode === 'rotate' && 'ring-2 ring-white/50')
"
:aria-label="t('load3d.gizmo.rotate')"
@click="setMode('rotate')"
>
<i class="pi pi-sync text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: t('load3d.gizmo.scale'),
showDelay: 300
}"
variant="textonly"
size="icon"
:class="
cn('rounded-full', gizmoMode === 'scale' && 'ring-2 ring-white/50')
"
:aria-label="t('load3d.gizmo.scale')"
@click="setMode('scale')"
>
<i class="pi pi-expand text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: t('load3d.gizmo.reset'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="t('load3d.gizmo.reset')"
@click="resetTransform"
>
<i class="pi pi-refresh text-lg text-base-foreground" />
</Button>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
GizmoConfig,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const gizmoConfig = defineModel<GizmoConfig>('gizmoConfig')
const gizmoEnabled = computed(() => gizmoConfig.value?.enabled ?? false)
const gizmoMode = computed(() => gizmoConfig.value?.mode ?? 'translate')
const emit = defineEmits<{
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const toggleGizmo = () => {
if (!gizmoConfig.value) return
gizmoConfig.value.enabled = !gizmoConfig.value.enabled
emit('toggleGizmo', gizmoConfig.value.enabled)
}
const setMode = (mode: GizmoMode) => {
if (!gizmoConfig.value) return
gizmoConfig.value.mode = mode
emit('setGizmoMode', mode)
}
const resetTransform = () => {
emit('resetGizmoTransform')
}
</script>

View File

@@ -0,0 +1,133 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import ViewerGizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { on: 'On', off: 'Off' },
load3d: {
gizmo: {
toggle: 'Gizmo',
translate: 'Translate',
rotate: 'Rotate',
scale: 'Scale',
reset: 'Reset Transform'
}
}
}
}
})
function renderComponent(
initial: { enabled?: boolean; mode?: GizmoMode } = {}
) {
const enabled = ref<boolean>(initial.enabled ?? false)
const mode = ref<GizmoMode>(initial.mode ?? 'translate')
const utils = render(ViewerGizmoControls, {
props: {
gizmoEnabled: enabled.value,
'onUpdate:gizmoEnabled': (v: boolean | undefined) => {
if (v !== undefined) enabled.value = v
},
gizmoMode: mode.value,
'onUpdate:gizmoMode': (v: GizmoMode | undefined) => {
if (v) mode.value = v
}
},
global: {
plugins: [i18n]
}
})
return { ...utils, enabled, mode, user: userEvent.setup() }
}
describe('ViewerGizmoControls', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders only the on/off toggle when gizmo is disabled', () => {
renderComponent({ enabled: false })
expect(screen.getByText('Gizmo')).toBeTruthy()
expect(screen.getByText('Off')).toBeTruthy()
expect(screen.getByText('On')).toBeTruthy()
expect(screen.queryByText('Translate')).toBeNull()
expect(screen.queryByText('Rotate')).toBeNull()
expect(screen.queryByText('Scale')).toBeNull()
expect(screen.queryByText('Reset Transform')).toBeNull()
})
it('renders mode toggles and reset button when gizmo is enabled', () => {
renderComponent({ enabled: true })
expect(screen.getByText('Translate')).toBeTruthy()
expect(screen.getByText('Rotate')).toBeTruthy()
expect(screen.getByText('Scale')).toBeTruthy()
expect(screen.getByText('Reset Transform')).toBeTruthy()
})
it('enables gizmo when the On item is clicked', async () => {
const { user, enabled } = renderComponent({ enabled: false })
await user.click(screen.getByText('On'))
expect(enabled.value).toBe(true)
})
it('disables gizmo when the Off item is clicked from an enabled state', async () => {
const { user, enabled } = renderComponent({ enabled: true })
await user.click(screen.getByText('Off'))
expect(enabled.value).toBe(false)
})
it.each([
['Translate', 'translate'],
['Rotate', 'rotate'],
['Scale', 'scale']
] as const)(
'updates mode to %s when its toggle item is clicked',
async (label, expected) => {
const { user, mode } = renderComponent({
enabled: true,
mode: 'translate'
})
await user.click(screen.getByText(label))
expect(mode.value).toBe(expected)
}
)
it('emits reset-transform when the reset button is clicked', async () => {
const { user, emitted } = renderComponent({
enabled: true,
mode: 'rotate'
})
await user.click(screen.getByRole('button', { name: /reset transform/i }))
expect(emitted()['reset-transform']).toEqual([[]])
})
it('leaves mode unchanged when deselecting the active mode', async () => {
const { user, mode } = renderComponent({ enabled: true, mode: 'scale' })
await user.click(screen.getByText('Scale'))
expect(mode.value).toBe('scale')
})
})

View File

@@ -0,0 +1,63 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<label>{{ $t('load3d.gizmo.toggle') }}</label>
<ToggleGroup
type="single"
:model-value="gizmoEnabled ? 'on' : 'off'"
@update:model-value="(v) => (gizmoEnabled = v === 'on')"
>
<ToggleGroupItem value="off" size="sm">
{{ $t('g.off') }}
</ToggleGroupItem>
<ToggleGroupItem value="on" size="sm">
{{ $t('g.on') }}
</ToggleGroupItem>
</ToggleGroup>
</div>
<template v-if="gizmoEnabled">
<div>
<ToggleGroup
type="single"
:model-value="gizmoMode"
@update:model-value="
(v) => {
if (v) gizmoMode = v as GizmoMode
}
"
>
<ToggleGroupItem value="translate">
{{ $t('load3d.gizmo.translate') }}
</ToggleGroupItem>
<ToggleGroupItem value="rotate">
{{ $t('load3d.gizmo.rotate') }}
</ToggleGroupItem>
<ToggleGroupItem value="scale">
{{ $t('load3d.gizmo.scale') }}
</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<Button variant="secondary" @click="$emit('reset-transform')">
<i class="pi pi-refresh" />
{{ $t('load3d.gizmo.reset') }}
</Button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
const gizmoEnabled = defineModel<boolean>('gizmoEnabled')
const gizmoMode = defineModel<GizmoMode>('gizmoMode')
defineEmits<{
(e: 'reset-transform'): void
}>()
</script>

View File

@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import type { SelectOption } from '@/components/ui/select/types'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Select/MultiSelect',

View File

@@ -155,9 +155,6 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
@@ -165,8 +162,10 @@ import {
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from './select.variants'
import type { SelectOption } from './types'
} from '@/components/ui/select/select.variants'
import type { SelectOption } from '@/components/ui/select/types'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false

View File

@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import SingleSelect from './SingleSelect.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import type { SelectOption } from './types'
const meta: Meta = {

View File

@@ -84,17 +84,16 @@ import {
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from './select.variants'
import type { SelectOption } from './types'
} from '@/components/ui/select/select.variants'
import type { SelectOption } from '@/components/ui/select/types'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false

View File

@@ -131,8 +131,8 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'

View File

@@ -7,9 +7,9 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'

View File

@@ -146,6 +146,12 @@ describe('useLoad3d', () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
setGizmoEnabled: vi.fn(),
setGizmoMode: vi.fn(),
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: vi.fn(),
setAnimationTime: vi.fn(),
renderer: {
domElement: mockCanvas
} as Partial<Load3d['renderer']> as Load3d['renderer']
@@ -169,38 +175,6 @@ describe('useLoad3d', () => {
})
describe('initialization', () => {
it('should initialize with default values', () => {
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value).toEqual({
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
expect(composable.modelConfig.value).toEqual({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
})
expect(composable.cameraConfig.value).toEqual({
cameraType: 'perspective',
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
expect(composable.loading.value).toBe(false)
})
it('should initialize Load3d with container and node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -229,8 +203,6 @@ describe('useLoad3d', () => {
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
@@ -271,53 +243,29 @@ describe('useLoad3d', () => {
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
})
it('should load model if model_file widget exists', async () => {
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'test.glb',
type: 'text'
} as IWidget)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/test.glb'
)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
it('should restore camera state after loading model', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'test.glb',
type: 'text'
} as IWidget)
;(mockNode.properties!['Camera Config'] as { state: unknown }).state = {
it('should restore camera config from node properties', async () => {
;(
mockNode.properties!['Camera Config'] as Record<string, unknown>
).state = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
@@ -325,7 +273,7 @@ describe('useLoad3d', () => {
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
expect(composable.cameraConfig.value.state).toEqual({
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
})
@@ -460,11 +408,13 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(mockNode.properties['Model Config']).toEqual({
upDirection: '+y',
materialMode: 'wireframe',
showSkeleton: false
})
const savedModelConfig = mockNode.properties['Model Config'] as Record<
string,
unknown
>
expect(savedModelConfig.upDirection).toBe('+y')
expect(savedModelConfig.materialMode).toBe('wireframe')
expect(savedModelConfig.showSkeleton).toBe(false)
})
it('should update camera config when values change', async () => {
@@ -862,79 +812,72 @@ describe('useLoad3d', () => {
})
})
describe('getModelUrl', () => {
it('should handle http URLs directly', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'http://example.com/model.glb',
type: 'text'
} as IWidget)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://example.com/model.glb'
)
})
it('should construct URL for local files', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'models/test.glb',
type: 'text'
} as IWidget)
describe('handleModelDrop', () => {
it('should upload file, construct URL, and load model', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'models',
'test.glb'
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/models/test.glb'
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/models/test.glb'
'http://localhost/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'models',
'test.glb',
'input'
)
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/models/test.glb'
'http://localhost/api/view/uploaded/model.glb'
)
})
it('should use output type for preview mode', async () => {
mockNode.widgets = [
{ name: 'model_file', value: 'test.glb', type: 'text' } as IWidget
] // No width/height widgets
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
it('should use resource folder for upload subfolder', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
'http://localhost/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'',
'test.glb',
'output'
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should not load model when load3d is not initialized', async () => {
const composable = useLoad3d(mockNode)
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
)
})
})
@@ -1071,4 +1014,241 @@ describe('useLoad3d', () => {
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
})
describe('gizmo controls', () => {
it('should include default gizmo config in modelConfig', () => {
const composable = useLoad3d(mockNode)
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('should restore gizmo config from node properties', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
})
})
it('should add default gizmo config when missing from saved config', async () => {
mockNode.properties!['Model Config'] = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo).toBeDefined()
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
})
it('should add default scale when gizmo config lacks scale', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
x: 1,
y: 1,
z: 1
})
})
it('handleToggleGizmo should enable gizmo and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
})
it('handleToggleGizmo should disable gizmo and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleToggleGizmo(false)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenLastCalledWith(false)
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
})
it('handleSetGizmoMode should set mode and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleSetGizmoMode('rotate')
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('handleResetGizmoTransform should call resetGizmoTransform', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleResetGizmoTransform()
expect(mockLoad3d.resetGizmoTransform).toHaveBeenCalled()
})
it('should persist gizmo config to node properties via modelConfig watcher', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
await nextTick()
const savedConfig = mockNode.properties['Model Config'] as {
gizmo: { enabled: boolean; mode: string }
}
expect(savedConfig.gizmo.enabled).toBe(true)
expect(savedConfig.gizmo.mode).toBe('rotate')
})
it('should register gizmoTransformChange event handler', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const gizmoEventCall = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)
expect(gizmoEventCall).toBeDefined()
})
it('gizmoTransformChange event should update modelConfig', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const gizmoEventCall = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)
const handler = gizmoEventCall![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
expect(composable.modelConfig.value.gizmo!.position).toEqual({
x: 5,
y: 6,
z: 7
})
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
x: 0.5,
y: 0.6,
z: 0.7
})
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
x: 3,
y: 3,
z: 3
})
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const loadingStartCall = addEventCalls.find(
([event]) => event === 'modelLoadingStart'
)
const loadingStartHandler = loadingStartCall![1] as () => void
const loadingEndCall = addEventCalls.find(
([event]) => event === 'modelLoadingEnd'
)
const loadingEndHandler = loadingEndCall![1] as () => void
loadingEndHandler()
loadingStartHandler()
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('should not call gizmo methods when load3d is not initialized', () => {
const composable = useLoad3d(mockNode)
// These should not throw
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
composable.handleResetGizmoTransform()
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,7 +2,7 @@ import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { nextTick, ref, toRaw, watch } from 'vue'
import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
@@ -16,6 +16,8 @@ import type {
CameraState,
CameraType,
EventCallback,
GizmoConfig,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
@@ -38,6 +40,7 @@ const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const nodeRef = toRef(nodeOrRef)
let load3d: Load3d | null = null
let isFirstModelLoad = true
const sceneConfig = ref<SceneConfig>({
showGrid: true,
@@ -49,7 +52,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const modelConfig = ref<ModelConfig>({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
})
const hasSkeleton = ref(false)
@@ -183,11 +193,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const savedModelConfig = node.properties['Model Config'] as ModelConfig
if (savedModelConfig) {
modelConfig.value = savedModelConfig
modelConfig.value = {
...savedModelConfig,
gizmo: savedModelConfig.gizmo
? {
...savedModelConfig.gizmo,
scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 }
}
: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
const cameraStateToRestore = savedCameraConfig?.state
if (savedCameraConfig) {
cameraConfig.value = savedCameraConfig
@@ -235,31 +258,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget?.value) {
const modelUrl = getModelUrl(modelWidget.value as string)
if (modelUrl) {
loading.value = true
loadingMessage.value = t('load3d.reloadingModel')
try {
await load3d.loadModel(modelUrl)
if (cameraStateToRestore) {
await nextTick()
load3d.setCameraState(cameraStateToRestore)
}
} catch (error) {
console.error('Failed to reload model:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
} else if (cameraStateToRestore) {
load3d.setCameraState(cameraStateToRestore)
}
applySceneConfigToLoad3d()
applyLightConfigToLoad3d()
}
@@ -276,6 +274,31 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const applyGizmoConfigToLoad3d = () => {
if (!load3d) return
const gizmo = modelConfig.value.gizmo
if (!gizmo) return
const hasTransform =
gizmo.position.x !== 0 ||
gizmo.position.y !== 0 ||
gizmo.position.z !== 0 ||
gizmo.rotation.x !== 0 ||
gizmo.rotation.y !== 0 ||
gizmo.rotation.z !== 0 ||
gizmo.scale.x !== 1 ||
gizmo.scale.y !== 1 ||
gizmo.scale.z !== 1
if (hasTransform) {
load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale)
}
if (gizmo.enabled) {
load3d.setGizmoEnabled(true)
}
if (gizmo.mode !== 'translate') {
load3d.setGizmoMode(gizmo.mode)
}
}
const applyLightConfigToLoad3d = () => {
if (!load3d) return
const cfg = lightConfig.value
@@ -294,29 +317,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const getModelUrl = (modelPath: string): string | null => {
if (!modelPath) return null
try {
if (modelPath.startsWith('http')) {
return modelPath
}
const trimmed = modelPath.trim()
const hasOutputSuffix = trimmed.endsWith('[output]')
const cleanPath = hasOutputSuffix
? trimmed.replace(/\s*\[output\]$/, '')
: trimmed
const type = hasOutputSuffix || isPreview.value ? 'output' : 'input'
const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath)
return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type))
} catch (error) {
console.error('Failed to construct model URL:', error)
return null
}
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
const rawNode = toRaw(nodeRef.value)
if (!rawNode) return
@@ -380,16 +380,34 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
watch(
modelConfig,
(newValue) => {
if (load3d && nodeRef.value) {
if (nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
load3d.setUpDirection(newValue.upDirection)
load3d.setMaterialMode(newValue.materialMode)
load3d.setShowSkeleton(newValue.showSkeleton)
}
},
{ deep: true }
)
watch(
() => modelConfig.value.upDirection,
(newValue) => {
if (load3d) load3d.setUpDirection(newValue)
}
)
watch(
() => modelConfig.value.materialMode,
(newValue) => {
if (load3d) load3d.setMaterialMode(newValue)
}
)
watch(
() => modelConfig.value.showSkeleton,
(newValue) => {
if (load3d) load3d.setShowSkeleton(newValue)
}
)
watch(
cameraConfig,
(newValue) => {
@@ -741,6 +759,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelLoadingStart: () => {
loadingMessage.value = t('load3d.loadingModel')
loading.value = true
if (!isFirstModelLoad) {
modelConfig.value = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
},
modelLoadingEnd: () => {
loadingMessage.value = ''
@@ -748,8 +780,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
hasSkeleton.value = load3d?.hasSkeleton() ?? false
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
applyGizmoConfigToLoad3d()
isFirstModelLoad = false
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
@@ -816,9 +848,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
}
},
gizmoTransformChange: (data: GizmoConfig) => {
if (modelConfig.value.gizmo && nodeRef.value) {
modelConfig.value.gizmo.position = data.position
modelConfig.value.gizmo.rotation = data.rotation
modelConfig.value.gizmo.scale = data.scale
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
}
} as const
const handleToggleGizmo = (enabled: boolean) => {
if (load3d && modelConfig.value.gizmo) {
modelConfig.value.gizmo.enabled = enabled
load3d.setGizmoEnabled(enabled)
}
}
const handleSetGizmoMode = (mode: GizmoMode) => {
if (load3d && modelConfig.value.gizmo) {
modelConfig.value.gizmo.mode = mode
load3d.setGizmoMode(mode)
}
}
const handleFitToViewer = () => {
if (load3d) {
load3d.fitToViewer()
}
}
const handleResetGizmoTransform = () => {
if (load3d) {
load3d.resetGizmoTransform()
}
}
const handleEvents = (action: 'add' | 'remove') => {
Object.entries(eventConfig).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
@@ -878,6 +945,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
cleanup
}
}

View File

@@ -110,7 +110,15 @@ describe('useLoad3dViewer', () => {
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false)
isPlyModel: vi.fn().mockReturnValue(false),
setGizmoEnabled: vi.fn(),
setGizmoMode: vi.fn(),
setBackgroundRenderMode: vi.fn(),
getGizmoTransform: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
}
mockSourceLoad3d = {
@@ -163,20 +171,6 @@ describe('useLoad3dViewer', () => {
})
describe('initialization', () => {
it('should initialize with default values', () => {
const viewer = useLoad3dViewer(mockNode)
expect(viewer.backgroundColor.value).toBe('')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.cameraType.value).toBe('perspective')
expect(viewer.fov.value).toBe(75)
expect(viewer.lightIntensity.value).toBe(1)
expect(viewer.backgroundImage.value).toBe('')
expect(viewer.hasBackgroundImage.value).toBe(false)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
})
it('should initialize viewer with source Load3d state', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -240,104 +234,7 @@ describe('useLoad3dViewer', () => {
})
})
describe('state watchers', () => {
it('should update background color when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundColor.value = '#ff0000'
await nextTick()
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000')
})
it('should update grid visibility when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.showGrid.value = false
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
})
it('should update camera type when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.cameraType.value = 'orthographic'
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
})
it('should update FOV when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.fov.value = 90
await nextTick()
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
})
it('should update light intensity when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.lightIntensity.value = 2
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2)
})
it('should update background image when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundImage.value = 'new-bg.jpg'
await nextTick()
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should update up direction when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.upDirection.value = '+y'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
})
it('should update material mode when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.materialMode.value = 'wireframe'
await nextTick()
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
})
describe('error handling', () => {
it('should handle watcher errors gracefully', async () => {
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
function () {
@@ -749,4 +646,118 @@ describe('useLoad3dViewer', () => {
expect(newViewer.backgroundColor.value).toBe('#0000ff')
})
})
describe('gizmo controls', () => {
it('should initialize gizmo state from node model config', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate'
}
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(viewer.gizmoEnabled.value).toBe(true)
expect(viewer.gizmoMode.value).toBe('rotate')
})
it('should default gizmo to disabled translate when no config', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(viewer.gizmoEnabled.value).toBe(false)
expect(viewer.gizmoMode.value).toBe('translate')
})
it('should persist gizmo state in applyChanges', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await viewer.applyChanges()
const modelConfig = mockNode.properties!['Model Config'] as Record<
string,
unknown
>
const gizmo = modelConfig.gizmo as Record<string, unknown>
expect(gizmo.enabled).toBe(true)
expect(gizmo.mode).toBe('rotate')
})
it('should save gizmo transform from load3d in applyChanges', async () => {
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue({
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
})
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
await viewer.applyChanges()
const modelConfig = mockNode.properties!['Model Config'] as Record<
string,
unknown
>
const gizmo = modelConfig.gizmo as {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
}
expect(gizmo.position).toEqual({ x: 1, y: 2, z: 3 })
expect(gizmo.rotation).toEqual({ x: 0.1, y: 0.2, z: 0.3 })
expect(gizmo.scale).toEqual({ x: 2, y: 2, z: 2 })
})
it('should restore gizmo state in restoreInitialState', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
viewer.restoreInitialState()
const modelConfig = mockNode.properties!['Model Config'] as Record<
string,
unknown
>
const gizmo = modelConfig.gizmo as Record<string, unknown>
expect(gizmo.enabled).toBe(false)
expect(gizmo.mode).toBe('translate')
})
it('should restore gizmo state from standalone config cache', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
const model1 = 'gizmo_model1.glb'
await viewer.initializeStandaloneViewer(containerRef, model1)
viewer.gizmoEnabled.value = true
viewer.gizmoMode.value = 'rotate'
await nextTick()
viewer.cleanup()
const restoredViewer = useLoad3dViewer()
await restoredViewer.initializeStandaloneViewer(containerRef, model1)
expect(restoredViewer.gizmoEnabled.value).toBe(true)
expect(restoredViewer.gizmoMode.value).toBe('rotate')
})
})
})

View File

@@ -9,6 +9,7 @@ import type {
CameraConfig,
CameraState,
CameraType,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
@@ -32,6 +33,8 @@ interface Load3dViewerState {
backgroundRenderMode: BackgroundRenderModeType
upDirection: UpDirection
materialMode: MaterialMode
gizmoEnabled: boolean
gizmoMode: GizmoMode
}
const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
@@ -44,7 +47,9 @@ const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
gizmoEnabled: false,
gizmoMode: 'translate'
}
const standaloneConfigCache = new QuickLRU<string, Load3dViewerState>({
@@ -69,6 +74,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundRenderMode = ref<BackgroundRenderModeType>('tiled')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const gizmoEnabled = ref(false)
const gizmoMode = ref<GizmoMode>('translate')
const needApplyChanges = ref(true)
const isPreview = ref(false)
const isStandaloneMode = ref(false)
@@ -98,7 +105,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
gizmoEnabled: false,
gizmoMode: 'translate'
})
watch(backgroundColor, (newColor) => {
@@ -273,6 +282,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
watch(gizmoEnabled, (newValue) => {
if (load3d) {
load3d.setGizmoEnabled(newValue)
}
})
watch(gizmoMode, (newValue) => {
if (load3d) {
load3d.setGizmoMode(newValue)
}
})
/**
* Initializes the viewer in node mode using a source Load3d instance.
*
@@ -367,6 +388,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
modelConfig.upDirection || source.modelManager.currentUpDirection
materialMode.value =
modelConfig.materialMode || source.modelManager.materialMode
if (modelConfig.gizmo) {
gizmoEnabled.value = modelConfig.gizmo.enabled
gizmoMode.value = modelConfig.gizmo.mode
}
}
isSplatModel.value = source.isSplatModel()
@@ -382,7 +407,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: backgroundImage.value,
backgroundRenderMode: backgroundRenderMode.value,
upDirection: upDirection.value,
materialMode: materialMode.value
materialMode: materialMode.value,
gizmoEnabled: gizmoEnabled.value,
gizmoMode: gizmoMode.value
}
setupAnimationEvents()
@@ -475,7 +502,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: backgroundImage.value,
backgroundRenderMode: backgroundRenderMode.value,
upDirection: upDirection.value,
materialMode: materialMode.value
materialMode: materialMode.value,
gizmoEnabled: gizmoEnabled.value,
gizmoMode: gizmoMode.value
})
}
@@ -497,6 +526,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundRenderMode.value = config.backgroundRenderMode
upDirection.value = config.upDirection
materialMode.value = config.materialMode
gizmoEnabled.value = config.gizmoEnabled
gizmoMode.value = config.gizmoMode
if (cached?.cameraState && load3d) {
load3d.setCameraState(cached.cameraState)
}
@@ -572,7 +603,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
nodeValue.properties['Model Config'] = {
upDirection: initialState.value.upDirection,
materialMode: initialState.value.materialMode
materialMode: initialState.value.materialMode,
gizmo: {
enabled: initialState.value.gizmoEnabled,
mode: initialState.value.gizmoMode,
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
const currentCameraConfig = nodeValue.properties['Camera Config'] as
@@ -614,9 +652,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
intensity: lightIntensity.value
}
const gizmoTransform = load3d.getGizmoTransform()
nodeValue.properties['Model Config'] = {
upDirection: upDirection.value,
materialMode: materialMode.value
materialMode: materialMode.value,
showSkeleton: false,
gizmo: {
enabled: gizmoEnabled.value,
mode: gizmoMode.value,
position: gizmoTransform.position,
rotation: gizmoTransform.rotation,
scale: gizmoTransform.scale
}
}
}
@@ -757,6 +804,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundRenderMode,
upDirection,
materialMode,
gizmoEnabled,
gizmoMode,
needApplyChanges,
isPreview,
isStandaloneMode,
@@ -784,6 +833,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
resetGizmoTransform: () => {
load3d?.resetGizmoTransform()
},
cleanup,
hasSkeleton: false,

View File

@@ -190,28 +190,40 @@ export class CameraManager implements CameraManagerInterface {
}
}
setupForModel(size: THREE.Vector3): void {
setupForModel(
size: THREE.Vector3,
center: THREE.Vector3 = new THREE.Vector3(0, size.y / 2, 0)
): void {
const maxDim = Math.max(size.x, size.y, size.z)
const distance = Math.max(size.x, size.z) * 2
const height = size.y * 2
const height = center.y + maxDim
this.perspectiveCamera.position.set(distance, height, distance)
this.orthographicCamera.position.set(distance, height, distance)
this.perspectiveCamera.position.set(
center.x + distance,
height,
center.z + distance
)
this.orthographicCamera.position.set(
center.x + distance,
height,
center.z + distance
)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
this.perspectiveCamera.lookAt(center)
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = Math.max(size.x, size.y, size.z) * 2
const frustumSize = maxDim * 2
const aspect = this.perspectiveCamera.aspect
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.lookAt(0, size.y / 2, 0)
this.orthographicCamera.lookAt(center)
this.orthographicCamera.updateProjectionMatrix()
}
this.controls?.target.set(0, size.y / 2, 0)
this.controls?.target.copy(center)
this.controls?.update()
}

View File

@@ -0,0 +1,368 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GizmoManager } from './GizmoManager'
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn()
}))
vi.mock('three/examples/jsm/controls/TransformControls', () => {
class TransformControls {
enabled = true
camera: THREE.Camera
private listeners = new Map<string, ((e: unknown) => void)[]>()
constructor(camera: THREE.Camera) {
this.camera = camera
}
addEventListener(event: string, cb: (e: unknown) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, [])
this.listeners.get(event)!.push(cb)
}
setMode = mockSetMode
attach = mockAttach
detach = mockDetach
getHelper = mockGetHelper
dispose = mockDispose
emit(event: string, data: unknown) {
for (const cb of this.listeners.get(event) ?? []) cb(data)
}
}
return { TransformControls }
})
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
class OrbitControls {
enabled = true
}
return { OrbitControls }
})
function makeMockOrbitControls() {
return { enabled: true } as unknown as InstanceType<
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
>
}
describe('GizmoManager', () => {
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let camera: THREE.PerspectiveCamera
let orbitControls: ReturnType<typeof makeMockOrbitControls>
let manager: GizmoManager
let onTransformChange: () => void
let mockHelper: THREE.Object3D
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
renderer = {
domElement: document.createElement('canvas')
} as unknown as THREE.WebGLRenderer
camera = new THREE.PerspectiveCamera()
orbitControls = makeMockOrbitControls()
onTransformChange = vi.fn()
mockHelper = new THREE.Object3D()
mockHelper.name = ''
mockHelper.renderOrder = 0
mockGetHelper.mockReturnValue(mockHelper)
manager = new GizmoManager(
scene,
renderer,
orbitControls,
() => camera,
onTransformChange
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('init', () => {
it('adds helper to scene with correct name and render order', () => {
manager.init()
expect(mockGetHelper).toHaveBeenCalled()
expect(mockHelper.name).toBe('GizmoTransformControls')
expect(mockHelper.renderOrder).toBe(999)
expect(scene.children).toContain(mockHelper)
})
})
describe('setupForModel', () => {
it('attaches to model and stores initial transform when enabled', () => {
manager.init()
manager.setEnabled(true)
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
manager.setupForModel(model)
expect(mockDetach).toHaveBeenCalled()
expect(mockAttach).toHaveBeenCalledWith(model)
expect(mockSetMode).toHaveBeenCalledWith('translate')
})
it('does not attach when disabled', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
expect(mockAttach).not.toHaveBeenCalled()
})
it('does nothing before init', () => {
const model = new THREE.Object3D()
manager.setupForModel(model)
expect(mockDetach).not.toHaveBeenCalled()
})
})
describe('setEnabled', () => {
it('attaches to target when enabled with a target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
vi.mocked(mockAttach).mockClear()
manager.setEnabled(true)
expect(mockAttach).toHaveBeenCalledWith(model)
expect(manager.isEnabled()).toBe(true)
})
it('detaches when disabled', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
vi.mocked(mockDetach).mockClear()
manager.setEnabled(false)
expect(mockDetach).toHaveBeenCalled()
expect(manager.isEnabled()).toBe(false)
})
it('does nothing before init', () => {
manager.setEnabled(true)
expect(mockAttach).not.toHaveBeenCalled()
})
})
describe('detach', () => {
it('detaches and clears target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
vi.mocked(mockDetach).mockClear()
manager.detach()
expect(mockDetach).toHaveBeenCalled()
expect(manager.isEnabled()).toBe(false)
})
})
describe('setMode / getMode', () => {
it('defaults to translate', () => {
expect(manager.getMode()).toBe('translate')
})
it('switches to rotate', () => {
manager.init()
manager.setMode('rotate')
expect(manager.getMode()).toBe('rotate')
expect(mockSetMode).toHaveBeenCalledWith('rotate')
})
it('stores mode before init', () => {
manager.setMode('rotate')
expect(manager.getMode()).toBe('rotate')
})
})
describe('reset', () => {
it('restores initial position, rotation, and scale', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(2, 2, 2)
manager.setupForModel(model)
model.position.set(10, 20, 30)
model.rotation.set(1, 2, 3)
model.scale.set(5, 5, 5)
manager.reset()
expect(model.position.x).toBeCloseTo(1)
expect(model.position.y).toBeCloseTo(2)
expect(model.position.z).toBeCloseTo(3)
expect(model.rotation.x).toBeCloseTo(0.1)
expect(model.rotation.y).toBeCloseTo(0.2)
expect(model.rotation.z).toBeCloseTo(0.3)
expect(model.scale.x).toBeCloseTo(2)
expect(model.scale.y).toBeCloseTo(2)
expect(model.scale.z).toBeCloseTo(2)
})
it('does nothing without a target', () => {
manager.init()
expect(() => manager.reset()).not.toThrow()
})
it('invokes onTransformChange after resetting', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
manager.setupForModel(model)
expect(onTransformChange).not.toHaveBeenCalled()
manager.reset()
expect(onTransformChange).toHaveBeenCalledOnce()
})
})
describe('applyTransform', () => {
it('sets position and rotation on target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyTransform({ x: 5, y: 6, z: 7 }, { x: 0.5, y: 0.6, z: 0.7 })
expect(model.position.x).toBeCloseTo(5)
expect(model.position.y).toBeCloseTo(6)
expect(model.position.z).toBeCloseTo(7)
expect(model.rotation.x).toBeCloseTo(0.5)
expect(model.rotation.y).toBeCloseTo(0.6)
expect(model.rotation.z).toBeCloseTo(0.7)
})
it('applies scale when provided', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyTransform(
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 2, y: 3, z: 4 }
)
expect(model.scale.x).toBeCloseTo(2)
expect(model.scale.y).toBeCloseTo(3)
expect(model.scale.z).toBeCloseTo(4)
})
it('does nothing without a target', () => {
manager.init()
expect(() =>
manager.applyTransform({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0 })
).not.toThrow()
})
})
describe('getTransform', () => {
it('returns current target transform', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const transform = manager.getTransform()
expect(transform.position).toEqual({ x: 1, y: 2, z: 3 })
expect(transform.rotation.x).toBeCloseTo(0.1)
expect(transform.rotation.y).toBeCloseTo(0.2)
expect(transform.rotation.z).toBeCloseTo(0.3)
expect(transform.scale).toEqual({ x: 4, y: 5, z: 6 })
})
it('returns zero/identity when no target', () => {
const transform = manager.getTransform()
expect(transform.position).toEqual({ x: 0, y: 0, z: 0 })
expect(transform.rotation).toEqual({ x: 0, y: 0, z: 0 })
expect(transform.scale).toEqual({ x: 1, y: 1, z: 1 })
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()
expect(scene.children).toContain(mockHelper)
manager.removeFromScene()
expect(scene.children).not.toContain(mockHelper)
})
it('restores helper to scene', () => {
manager.init()
manager.removeFromScene()
manager.ensureHelperInScene()
expect(scene.children).toContain(mockHelper)
})
})
describe('dispose', () => {
it('removes helper, detaches, and disposes controls', () => {
manager.init()
scene.add(mockHelper)
manager.dispose()
expect(mockDetach).toHaveBeenCalled()
expect(mockDispose).toHaveBeenCalled()
})
it('is safe to call before init', () => {
expect(() => manager.dispose()).not.toThrow()
})
})
describe('ensureHelperInScene', () => {
it('re-adds helper if it was removed from its parent', () => {
manager.init()
// Simulate helper being removed from scene
scene.remove(mockHelper)
expect(scene.children).not.toContain(mockHelper)
// setEnabled triggers ensureHelperInScene internally
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
expect(scene.children).toContain(mockHelper)
})
})
})

View File

@@ -0,0 +1,229 @@
import * as THREE from 'three'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
private targetObject: THREE.Object3D | null = null
private initialPosition: THREE.Vector3 = new THREE.Vector3()
private initialRotation: THREE.Euler = new THREE.Euler()
private initialScale: THREE.Vector3 = new THREE.Vector3(1, 1, 1)
private enabled: boolean = false
private activeCamera: THREE.Camera
private mode: GizmoMode = 'translate'
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private orbitControls: OrbitControls
private onTransformChange?: () => void
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
orbitControls: OrbitControls,
getActiveCamera: () => THREE.Camera,
onTransformChange?: () => void
) {
this.scene = scene
this.renderer = renderer
this.orbitControls = orbitControls
this.activeCamera = getActiveCamera()
this.onTransformChange = onTransformChange
}
init(): void {
this.transformControls = new TransformControls(
this.activeCamera,
this.renderer.domElement
)
this.transformControls.addEventListener('dragging-changed', (event) => {
this.orbitControls.enabled = !event.value
if (!event.value && this.onTransformChange) {
this.onTransformChange()
}
})
const helper = this.transformControls.getHelper()
helper.name = 'GizmoTransformControls'
helper.renderOrder = 999
this.scene.add(helper)
}
setupForModel(model: THREE.Object3D): void {
if (!this.transformControls) return
this.ensureHelperInScene()
this.transformControls.detach()
this.transformControls.enabled = false
this.targetObject = model
this.initialPosition.copy(model.position)
this.initialRotation.copy(model.rotation)
this.initialScale.copy(model.scale)
if (this.enabled) {
this.transformControls.attach(model)
this.transformControls.setMode(this.mode)
this.transformControls.enabled = true
}
}
detach(): void {
this.enabled = false
if (this.transformControls) {
this.transformControls.detach()
this.transformControls.enabled = false
}
this.targetObject = null
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
if (!this.transformControls) return
this.ensureHelperInScene()
if (enabled && this.targetObject) {
this.transformControls.attach(this.targetObject)
this.transformControls.setMode(this.mode)
this.transformControls.enabled = true
} else {
this.transformControls.detach()
this.transformControls.enabled = false
}
}
ensureHelperInScene(): void {
if (!this.transformControls) return
const helper = this.transformControls.getHelper()
if (!helper.parent) {
this.scene.add(helper)
}
}
removeFromScene(): void {
if (!this.transformControls) return
const helper = this.transformControls.getHelper()
if (helper.parent) {
helper.parent.remove(helper)
}
}
isEnabled(): boolean {
return this.enabled
}
updateCamera(camera: THREE.Camera): void {
this.activeCamera = camera
if (this.transformControls) {
this.transformControls.camera = camera
}
}
setMode(mode: GizmoMode): void {
this.mode = mode
if (this.transformControls) {
this.transformControls.setMode(mode)
}
}
getMode(): GizmoMode {
return this.mode
}
reset(): void {
if (!this.targetObject) return
this.targetObject.position.copy(this.initialPosition)
this.targetObject.rotation.copy(this.initialRotation)
this.targetObject.scale.copy(this.initialScale)
this.onTransformChange?.()
}
applyTransform(
position: { x: number; y: number; z: number },
rotation: { x: number; y: number; z: number },
scale?: { x: number; y: number; z: number }
): void {
if (!this.targetObject) return
this.targetObject.position.set(position.x, position.y, position.z)
this.targetObject.rotation.set(rotation.x, rotation.y, rotation.z)
if (scale) {
this.targetObject.scale.set(scale.x, scale.y, scale.z)
}
}
getInitialTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
return {
position: {
x: this.initialPosition.x,
y: this.initialPosition.y,
z: this.initialPosition.z
},
rotation: {
x: this.initialRotation.x,
y: this.initialRotation.y,
z: this.initialRotation.z
},
scale: {
x: this.initialScale.x,
y: this.initialScale.y,
z: this.initialScale.z
}
}
}
getTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
if (!this.targetObject) {
return {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
return {
position: {
x: this.targetObject.position.x,
y: this.targetObject.position.y,
z: this.targetObject.position.z
},
rotation: {
x: this.targetObject.rotation.x,
y: this.targetObject.rotation.y,
z: this.targetObject.rotation.z
},
scale: {
x: this.targetObject.scale.x,
y: this.targetObject.scale.y,
z: this.targetObject.scale.z
}
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()
this.scene.remove(helper)
this.transformControls.detach()
this.transformControls.dispose()
this.transformControls = null
}
this.targetObject = null
}
}

View File

@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type {
GizmoConfig,
ModelConfig
} from '@/extensions/core/load3d/interfaces'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (p: string) => p,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchCustomEvent: vi.fn(),
fetchApi: vi.fn(),
getSystemStats: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { extra: {} } }
}))
vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: class {} }))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn()
}
}))
type WithPrivate = { loadModelConfig(): ModelConfig }
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
const load3d = {} as Load3d
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
}
const defaultGizmo: GizmoConfig = {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
describe('Load3DConfiguration.loadModelConfig', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns full defaults including gizmo when no properties are provided', () => {
const result = createConfig().loadModelConfig()
expect(result).toEqual({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: defaultGizmo
})
})
it('returns full defaults when properties do not contain Model Config', () => {
const result = createConfig({ 'Other Key': 'x' }).loadModelConfig()
expect(result.gizmo).toEqual(defaultGizmo)
})
it('adds default gizmo when Model Config exists but has no gizmo field', () => {
const stored: ModelConfig = {
upDirection: '+y',
materialMode: 'wireframe',
showSkeleton: true
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
const result = createConfig(properties).loadModelConfig()
expect(result.upDirection).toBe('+y')
expect(result.materialMode).toBe('wireframe')
expect(result.showSkeleton).toBe(true)
expect(result.gizmo).toEqual(defaultGizmo)
})
it('mutates the original Model Config property to persist gizmo defaults', () => {
const stored: ModelConfig = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
createConfig(properties).loadModelConfig()
expect((properties['Model Config'] as ModelConfig).gizmo).toEqual(
defaultGizmo
)
})
it('backfills scale on legacy gizmo config missing the scale field', () => {
const legacyGizmo = {
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 }
} as unknown as GizmoConfig
const stored: ModelConfig = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: legacyGizmo
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
const result = createConfig(properties).loadModelConfig()
expect(result.gizmo).toEqual({
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('preserves a fully populated gizmo config unchanged', () => {
const fullGizmo: GizmoConfig = {
enabled: true,
mode: 'scale',
position: { x: 5, y: 6, z: 7 },
rotation: { x: 1, y: 2, z: 3 },
scale: { x: 2, y: 2, z: 2 }
}
const stored: ModelConfig = {
upDirection: '-z',
materialMode: 'normal',
showSkeleton: false,
gizmo: fullGizmo
}
const properties = { 'Model Config': stored } as Dictionary<
NodeProperty | undefined
>
const result = createConfig(properties).loadModelConfig()
expect(result.gizmo).toEqual(fullGizmo)
})
})

View File

@@ -167,13 +167,32 @@ class Load3DConfiguration {
private loadModelConfig(): ModelConfig {
if (this.properties && 'Model Config' in this.properties) {
return this.properties['Model Config'] as ModelConfig
const config = this.properties['Model Config'] as ModelConfig
if (!config.gizmo) {
config.gizmo = {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
} else if (!config.gizmo.scale) {
config.gizmo.scale = { x: 1, y: 1, z: 1 }
}
return config
}
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}

View File

@@ -0,0 +1,269 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Load3d from '@/extensions/core/load3d/Load3d'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
type GizmoStub = {
setEnabled: ReturnType<typeof vi.fn>
setMode: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
applyTransform: ReturnType<typeof vi.fn>
getTransform: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
updateCamera: ReturnType<typeof vi.fn>
detach: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
removeFromScene: ReturnType<typeof vi.fn>
ensureHelperInScene: ReturnType<typeof vi.fn>
isEnabled: ReturnType<typeof vi.fn>
getMode: ReturnType<typeof vi.fn>
}
type ModelManagerStub = {
fitToViewer: ReturnType<typeof vi.fn>
clearModel: ReturnType<typeof vi.fn>
}
type CameraManagerStub = {
toggleCamera: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
type SceneManagerStub = {
captureScene: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
}
type Load3dPrivate = {
setGizmo(model: THREE.Object3D): void
setupCamera(size: THREE.Vector3, center: THREE.Vector3): void
}
function makeGizmoStub(): GizmoStub {
return {
setEnabled: vi.fn(),
setMode: vi.fn(),
reset: vi.fn(),
applyTransform: vi.fn(),
getTransform: vi.fn(() => ({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})),
setupForModel: vi.fn(),
updateCamera: vi.fn(),
detach: vi.fn(),
dispose: vi.fn(),
removeFromScene: vi.fn(),
ensureHelperInScene: vi.fn(),
isEnabled: vi.fn(() => false),
getMode: vi.fn(() => 'translate')
}
}
function makeInstance() {
const gizmo = makeGizmoStub()
const modelManager: ModelManagerStub = {
fitToViewer: vi.fn(),
clearModel: vi.fn()
}
const cameraManager: CameraManagerStub = {
toggleCamera: vi.fn(),
setupForModel: vi.fn(),
reset: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneManagerStub = {
captureScene: vi.fn(),
dispose: vi.fn()
}
const controlsManager = { updateCamera: vi.fn() }
const viewHelperManager = { recreateViewHelper: vi.fn() }
const animationManager = { dispose: vi.fn() }
// Load3d's constructor instantiates THREE.WebGLRenderer, ResizeObserver
// and ViewHelper, none of which are available in happy-dom. Skip it and
// inject stubs directly onto the prototype instance so delegation methods
// can be exercised in isolation.
const load3d = Object.create(Load3d.prototype) as Load3d
Object.assign(load3d, {
gizmoManager: gizmo,
modelManager,
cameraManager,
sceneManager,
controlsManager,
viewHelperManager,
animationManager,
forceRender: vi.fn(),
handleResize: vi.fn()
})
return {
load3d,
gizmo,
modelManager,
cameraManager,
sceneManager,
controlsManager,
viewHelperManager,
animationManager,
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
}
}
describe('Load3d', () => {
let ctx: ReturnType<typeof makeInstance>
beforeEach(() => {
ctx = makeInstance()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('gizmo delegation', () => {
it('getGizmoManager returns the underlying manager', () => {
expect(ctx.load3d.getGizmoManager()).toBe(ctx.gizmo)
})
it('setGizmoEnabled delegates to gizmoManager.setEnabled and forces a render', () => {
ctx.load3d.setGizmoEnabled(true)
expect(ctx.gizmo.setEnabled).toHaveBeenCalledWith(true)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it.each(['translate', 'rotate', 'scale'] as const)(
'setGizmoMode delegates "%s" and forces a render',
(mode: GizmoMode) => {
ctx.load3d.setGizmoMode(mode)
expect(ctx.gizmo.setMode).toHaveBeenCalledWith(mode)
expect(ctx.forceRender).toHaveBeenCalledOnce()
}
)
it('resetGizmoTransform delegates to gizmoManager.reset and forces a render', () => {
ctx.load3d.resetGizmoTransform()
expect(ctx.gizmo.reset).toHaveBeenCalledOnce()
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('applyGizmoTransform forwards position, rotation and scale', () => {
const pos = { x: 1, y: 2, z: 3 }
const rot = { x: 0.1, y: 0.2, z: 0.3 }
const scale = { x: 2, y: 2, z: 2 }
ctx.load3d.applyGizmoTransform(pos, rot, scale)
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, scale)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('applyGizmoTransform forwards undefined scale when not provided', () => {
const pos = { x: 0, y: 0, z: 0 }
const rot = { x: 0, y: 0, z: 0 }
ctx.load3d.applyGizmoTransform(pos, rot)
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
})
it('getGizmoTransform returns the gizmoManager transform', () => {
const transform = {
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
ctx.gizmo.getTransform.mockReturnValue(transform)
expect(ctx.load3d.getGizmoTransform()).toEqual(transform)
})
it('fitToViewer delegates to modelManager and forces a render', () => {
ctx.load3d.fitToViewer()
expect(ctx.modelManager.fitToViewer).toHaveBeenCalledOnce()
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
describe('lifecycle interactions', () => {
it('clearModel detaches the gizmo before clearing the model', () => {
const order: string[] = []
ctx.animationManager.dispose.mockImplementation(() =>
order.push('animation')
)
ctx.gizmo.detach.mockImplementation(() => order.push('detach'))
ctx.modelManager.clearModel.mockImplementation(() => order.push('clear'))
ctx.load3d.clearModel()
expect(order).toEqual(['animation', 'detach', 'clear'])
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('toggleCamera updates both controls and gizmo with the active camera', () => {
ctx.load3d.toggleCamera('orthographic')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.gizmo.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => {
const model = new THREE.Object3D()
;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model)
expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model)
})
it('setupCamera (private) forwards size and center to cameraManager', () => {
const size = new THREE.Vector3(1, 2, 3)
const center = new THREE.Vector3(4, 5, 6)
;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center)
expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center)
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
ctx.sceneManager.captureScene.mockResolvedValue(captureResult)
const result = await ctx.load3d.captureScene(100, 200)
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledBefore(
ctx.sceneManager.captureScene
)
expect(ctx.sceneManager.captureScene).toHaveBeenCalledWith(100, 200)
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
expect(result).toBe(captureResult)
})
it('restores the gizmo helper even when capture fails', async () => {
const err = new Error('capture failed')
ctx.sceneManager.captureScene.mockRejectedValue(err)
await expect(ctx.load3d.captureScene(100, 200)).rejects.toBe(err)
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledOnce()
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
})
})
})

View File

@@ -7,6 +7,7 @@ import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { HDRIManager } from './HDRIManager'
import { GizmoManager } from './GizmoManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
@@ -14,13 +15,14 @@ import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import {
type CameraState,
type CaptureResult,
type EventCallback,
type Load3DOptions,
type MaterialMode,
type UpDirection
import type {
CameraState,
CaptureResult,
EventCallback,
GizmoMode,
Load3DOptions,
MaterialMode,
UpDirection
} from './interfaces'
function positionThumbnailCamera(
@@ -61,6 +63,7 @@ class Load3d {
modelManager: SceneModelManager
recordingManager: RecordingManager
animationManager: AnimationManager
gizmoManager: GizmoManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
@@ -146,7 +149,8 @@ class Load3d {
this.renderer,
this.eventManager,
this.getActiveCamera.bind(this),
this.setupCamera.bind(this)
this.setupCamera.bind(this),
this.setGizmo.bind(this)
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
@@ -158,12 +162,29 @@ class Load3d {
)
this.animationManager = new AnimationManager(this.eventManager)
this.gizmoManager = new GizmoManager(
this.sceneManager.scene,
this.renderer,
this.controlsManager.controls,
this.getActiveCamera.bind(this),
() => {
const transform = this.gizmoManager.getTransform()
this.eventManager.emitEvent('gizmoTransformChange', {
...transform,
enabled: this.gizmoManager.isEnabled(),
mode: this.gizmoManager.getMode()
})
}
)
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
@@ -287,6 +308,10 @@ class Load3d {
return this.recordingManager
}
getGizmoManager(): GizmoManager {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
@@ -388,8 +413,12 @@ class Load3d {
return this.controlsManager.controls
}
private setupCamera(size: THREE.Vector3): void {
this.cameraManager.setupForModel(size)
private setGizmo(model: THREE.Object3D): void {
this.gizmoManager.setupForModel(model)
}
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
this.cameraManager.setupForModel(size, center)
}
private startAnimation(): void {
@@ -551,6 +580,7 @@ class Load3d {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
this.viewHelperManager.recreateViewHelper()
this.handleResize()
@@ -601,6 +631,7 @@ class Load3d {
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
@@ -629,6 +660,7 @@ class Load3d {
clearModel(): void {
this.animationManager.dispose()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.forceRender()
}
@@ -736,7 +768,11 @@ class Load3d {
}
captureScene(width: number, height: number): Promise<CaptureResult> {
return this.sceneManager.captureScene(width, height)
this.gizmoManager.removeFromScene()
return this.sceneManager.captureScene(width, height).finally(() => {
this.gizmoManager.ensureHelperInScene()
})
}
public async startRecording(): Promise<void> {
@@ -853,7 +889,7 @@ class Load3d {
this.controlsManager.controls.update()
}
const result = await this.sceneManager.captureScene(width, height)
const result = await this.captureScene(width, height)
return result.scene
} finally {
this.sceneManager.gridHelper.visible = savedGridVisible
@@ -866,6 +902,43 @@ class Load3d {
}
}
public setGizmoEnabled(enabled: boolean): void {
this.gizmoManager.setEnabled(enabled)
this.forceRender()
}
public setGizmoMode(mode: GizmoMode): void {
this.gizmoManager.setMode(mode)
this.forceRender()
}
public resetGizmoTransform(): void {
this.gizmoManager.reset()
this.forceRender()
}
public applyGizmoTransform(
position: { x: number; y: number; z: number },
rotation: { x: number; y: number; z: number },
scale?: { x: number; y: number; z: number }
): void {
this.gizmoManager.applyTransform(position, rotation, scale)
this.forceRender()
}
public getGizmoTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
return this.gizmoManager.getTransform()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
@@ -899,6 +972,7 @@ class Load3d {
this.modelManager.dispose()
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()

View File

@@ -9,10 +9,10 @@ import {
} from './interfaces'
export class SceneManager implements SceneManagerInterface {
scene: THREE.Scene
scene!: THREE.Scene
gridHelper: THREE.GridHelper
backgroundScene: THREE.Scene
backgroundScene!: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
@@ -38,6 +38,8 @@ export class SceneManager implements SceneManagerInterface {
this.eventManager = eventManager
this.scene = new THREE.Scene()
this.scene.name = 'MainScene'
this.getActiveCamera = getActiveCamera
this.gridHelper = new THREE.GridHelper(20, 20)
@@ -45,6 +47,7 @@ export class SceneManager implements SceneManagerInterface {
this.scene.add(this.gridHelper)
this.backgroundScene = new THREE.Scene()
this.backgroundScene.name = 'BackgroundScene'
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.initBackgroundScene()
@@ -93,6 +96,8 @@ export class SceneManager implements SceneManagerInterface {
this.scene.background = null
}
this.backgroundScene.clear()
this.scene.clear()
}

View File

@@ -37,14 +37,16 @@ export class SceneModelManager implements ModelManagerInterface {
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3) => void
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
private setupGizmo: (model: THREE.Object3D) => void
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3) => void
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
setupGizmo: (model: THREE.Object3D) => void
) {
this.scene = scene
this.renderer = renderer
@@ -52,6 +54,7 @@ export class SceneModelManager implements ModelManagerInterface {
this.activeCamera = getActiveCamera()
this.setupCamera = setupCamera
this.textureLoader = new THREE.TextureLoader()
this.setupGizmo = setupGizmo
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
@@ -371,32 +374,31 @@ export class SceneModelManager implements ModelManagerInterface {
clearModel(): void {
const objectsToRemove: THREE.Object3D[] = []
this.scene.traverse((object) => {
for (const object of [...this.scene.children]) {
const isEnvironmentObject =
object instanceof THREE.GridHelper ||
object instanceof THREE.Light ||
object instanceof THREE.Camera
object instanceof THREE.Camera ||
object.name === 'GizmoTransformControls'
if (!isEnvironmentObject) {
objectsToRemove.push(object)
}
})
}
objectsToRemove.forEach((obj) => {
if (obj.parent && obj.parent !== this.scene) {
obj.parent.remove(obj)
} else {
this.scene.remove(obj)
}
this.scene.remove(obj)
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) {
obj.material.forEach((material) => material.dispose())
} else {
obj.material?.dispose()
obj.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose()
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material?.dispose()
}
}
}
})
})
this.reset()
@@ -497,25 +499,10 @@ export class SceneModelManager implements ModelManagerInterface {
// SplatMesh handles its own rendering, just add to scene
this.scene.add(model)
// Set a default camera distance for splat models
this.setupCamera(new THREE.Vector3(5, 5, 5))
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
return
}
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const scale = targetSize / maxDim
model.scale.multiplyScalar(scale)
box.setFromObject(model)
box.getCenter(center)
box.getSize(size)
model.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(model)
if (this.materialMode !== 'original') {
@@ -527,7 +514,47 @@ export class SceneModelManager implements ModelManagerInterface {
}
this.setupModelMaterials(model)
this.setupCamera(size)
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
this.setupCamera(size, center)
this.setupGizmo(model)
}
fitToViewer(): void {
if (!this.currentModel || this.containsSplatMesh()) return
const model = this.currentModel
// Reset transform to compute from raw geometry (idempotent)
model.scale.set(1, 1, 1)
model.position.set(0, 0, 0)
model.rotation.set(0, 0, 0)
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
if (maxDim === 0) return
const targetSize = 5
const scale = targetSize / maxDim
model.scale.set(scale, scale, scale)
box.setFromObject(model)
box.getCenter(center)
box.getSize(size)
model.position.set(-center.x, -box.min.y, -center.z)
const newBox = new THREE.Box3().setFromObject(model)
const newSize = newBox.getSize(new THREE.Vector3())
const newCenter = newBox.getCenter(new THREE.Vector3())
this.setupCamera(newSize, newCenter)
this.setupGizmo(model)
}
containsSplatMesh(model?: THREE.Object3D | null): boolean {
@@ -548,6 +575,8 @@ export class SceneModelManager implements ModelManagerInterface {
setUpDirection(direction: UpDirection): void {
if (!this.currentModel) return
const directionChanged = this.currentUpDirection !== direction
if (!this.originalRotation && this.currentModel.rotation) {
this.originalRotation = this.currentModel.rotation.clone()
}
@@ -581,5 +610,9 @@ export class SceneModelManager implements ModelManagerInterface {
}
this.eventManager.emitEvent('upDirectionChange', direction)
if (directionChanged) {
this.setupGizmo(this.currentModel)
}
}
}

View File

@@ -33,10 +33,21 @@ export interface SceneConfig {
backgroundRenderMode?: BackgroundRenderModeType
}
export type GizmoMode = 'translate' | 'rotate' | 'scale'
export interface GizmoConfig {
enabled: boolean
mode: GizmoMode
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
}
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
showSkeleton: boolean
gizmo?: GizmoConfig
}
export interface CameraConfig {

View File

@@ -129,6 +129,8 @@
"saveAnyway": "Save Anyway",
"saving": "Saving",
"no": "No",
"on": "On",
"off": "Off",
"cancel": "Cancel",
"close": "Close",
"closeDialog": "Close dialog",
@@ -1941,6 +1943,7 @@
"upDirection": "Up Direction",
"materialMode": "Material Mode",
"showSkeleton": "Show Skeleton",
"fitToViewer": "Fit to Viewer",
"scene": "Scene",
"model": "Model",
"camera": "Camera",
@@ -1997,6 +2000,14 @@
"removeFile": "Remove HDRI",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
},
"gizmo": {
"label": "Gizmo",
"toggle": "Gizmo",
"translate": "Translate",
"rotate": "Rotate",
"scale": "Scale",
"reset": "Reset Transform"
}
},
"imageCrop": {
@@ -2094,7 +2105,9 @@
"failedToUploadBackgroundImage": "Failed to upload background image",
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
"failedToLoadHDRI": "Failed to load HDRI file",
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file.",
"failedToToggleGizmo": "Failed to toggle gizmo",
"failedToSetGizmoMode": "Failed to set gizmo mode"
},
"nodeErrors": {
"render": "Node Render Error",

View File

@@ -24,7 +24,7 @@ const i18n = createI18n({
})
// Mock components with minimal functionality for business logic testing
vi.mock('@/components/input/MultiSelect.vue', () => ({
vi.mock('@/components/ui/multi-select/MultiSelect.vue', () => ({
default: {
name: 'MultiSelect',
props: {
@@ -46,7 +46,7 @@ vi.mock('@/components/input/MultiSelect.vue', () => ({
}
}))
vi.mock('@/components/input/SingleSelect.vue', () => ({
vi.mock('@/components/ui/single-select/SingleSelect.vue', () => ({
default: {
name: 'SingleSelect',
props: {

View File

@@ -59,9 +59,9 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import type { SelectOption } from '@/components/ui/select/types'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type {

View File

@@ -46,7 +46,7 @@
</template>
<script setup lang="ts">
import SingleSelect from '@/components/input/SingleSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'

View File

@@ -3,7 +3,7 @@ import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SelectOption } from '@/components/input/types'
import type { SelectOption } from '@/components/ui/select/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OwnershipFilterOption } from '@/platform/assets/types/filterTypes'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'

View File

@@ -23,7 +23,9 @@
cn(
WidgetInputBaseClass,
'size-full resize-none text-xs',
!hideLayoutField && 'pt-5'
!hideLayoutField && 'pt-5',
// Avoid overflow-auto when idle to prevent per-textarea compositing layers.
'overflow-hidden hover:overflow-auto focus:overflow-auto'
)
"
:placeholder

View File

@@ -216,6 +216,9 @@ class Load3dService {
async copyLoad3dState(source: Load3d, target: Load3d) {
const sourceModel = source.modelManager.currentModel
const gizmoWasEnabled = target.getGizmoManager().isEnabled()
target.getGizmoManager().detach()
if (sourceModel) {
// Remove existing model from target scene before adding new one
const existingModel = target.getModelManager().currentModel
@@ -256,6 +259,36 @@ class Load3dService {
source.getModelManager().appliedTexture
}
const sourceInitial = source.getGizmoManager().getInitialTransform()
modelClone.position.set(
sourceInitial.position.x,
sourceInitial.position.y,
sourceInitial.position.z
)
modelClone.rotation.set(
sourceInitial.rotation.x,
sourceInitial.rotation.y,
sourceInitial.rotation.z
)
modelClone.scale.set(
sourceInitial.scale.x,
sourceInitial.scale.y,
sourceInitial.scale.z
)
target.getGizmoManager().setupForModel(modelClone)
const gizmoTransform = source.getGizmoTransform()
target.applyGizmoTransform(
gizmoTransform.position,
gizmoTransform.rotation,
gizmoTransform.scale
)
const shouldEnable =
gizmoWasEnabled || source.getGizmoManager().isEnabled()
if (shouldEnable) {
target.setGizmoEnabled(true)
}
// Copy animation state
if (source.hasAnimations()) {
target.animationManager.setupModelAnimations(

View File

@@ -157,7 +157,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import SearchAutocomplete from '@/components/ui/search-input/SearchAutocomplete.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'

View File

@@ -0,0 +1,32 @@
# @comfyorg/test-recorder
Interactive CLI for recording and transforming Playwright browser tests for ComfyUI.
## Quick Start
**Prerequisites:** Node.js ≥ 20, pnpm, a running ComfyUI backend. See the [Browser Tests README](../../browser_tests/README.md) for detailed environment setup including Playwright installation and backend configuration.
```bash
pnpm comfy-test check # Verify your environment is ready
pnpm comfy-test record # Record a new test
pnpm comfy-test transform <file> # Transform raw codegen to conventions
pnpm comfy-test list # List available workflows
```
## For QA Testers
See the [Browser Tests README](../../browser_tests/README.md) for full setup instructions.
## Development
```bash
cd tools/test-recorder
pnpm build # Compile TypeScript
pnpm dev # Watch mode
```
Run unit tests from the repo root:
```bash
pnpm test:unit -- tools/test-recorder
```

View File

@@ -0,0 +1,23 @@
{
"name": "@comfyorg/test-recorder",
"version": "0.1.0",
"private": true,
"description": "Interactive CLI for recording and transforming Playwright browser tests",
"bin": {
"comfy-test": "./dist/index.js"
},
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.9.1",
"picocolors": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,33 @@
import { pass, fail, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkBackend(port = 8188): Promise<CheckResult> {
const url = `http://localhost:${port}`
try {
const res = await fetch(`${url}/system_stats`, {
signal: AbortSignal.timeout(3000)
})
if (res.ok) {
pass('ComfyUI backend', url)
return { name: 'ComfyUI backend', ok: true, version: url }
}
throw new Error(`Status ${res.status}`)
} catch {
fail('ComfyUI backend', `not running on :${port}`)
const instructions = [
'ComfyUI backend must be running for browser tests.',
'',
'In a separate terminal, navigate to your ComfyUI folder and run:',
'',
' python main.py --multi-user',
'',
'⚠️ The --multi-user flag is REQUIRED for parallel test support.'
]
info(instructions)
return {
name: 'ComfyUI backend',
ok: false,
installInstructions: instructions
}
}
}

View File

@@ -0,0 +1,25 @@
import { pass, fail, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkDevServer(port = 5173): Promise<CheckResult> {
const url = `http://localhost:${port}`
try {
const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
if (res.ok || res.status === 304) {
pass('Dev server', url)
return { name: 'Dev server', ok: true, version: url }
}
throw new Error(`Status ${res.status}`)
} catch {
fail('Dev server', `not running on :${port}`)
const instructions = [
'Start the Vite dev server in another terminal:',
'',
' pnpm dev',
'',
`Then wait for it to show "Local: http://localhost:${port}"`
]
info(instructions)
return { name: 'Dev server', ok: false, installInstructions: instructions }
}
}

View File

@@ -0,0 +1,55 @@
import { execSync } from 'node:child_process'
import { detectPlatform } from './platform'
import { pass, warn, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkGh(): Promise<CheckResult> {
try {
const version = execSync('gh --version', { encoding: 'utf-8' })
.split('\n')[0]
.trim()
// Check if authenticated
try {
execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' })
pass('GitHub CLI (gh)', version.replace('gh version ', ''))
return { name: 'GitHub CLI', ok: true, optional: true, version }
} catch {
warn('GitHub CLI (gh)', 'installed but not authenticated')
info([
'Run `gh auth login` to authenticate.',
"Without auth, we'll help you create PRs manually."
])
return { name: 'GitHub CLI', ok: false, optional: true }
}
} catch {
warn('GitHub CLI (gh)', 'not installed (optional)')
const platform = detectPlatform()
const instructions =
platform === 'macos'
? [
"gh CLI lets us auto-create PRs. Without it, we'll help",
'you create the PR manually on github.com.',
'',
'To install (optional):',
' brew install gh',
' gh auth login'
]
: platform === 'windows'
? [
'gh CLI is optional. Install from: https://cli.github.com/',
"Without it, we'll help you create PRs via github.com."
]
: [
'gh CLI is optional. Install:',
' sudo apt install gh # Debian/Ubuntu',
' gh auth login'
]
info(instructions)
return {
name: 'GitHub CLI',
ok: false,
optional: true,
installInstructions: instructions
}
}
}

View File

@@ -0,0 +1,37 @@
import { execSync } from 'node:child_process'
import { detectPlatform } from './platform'
import { pass, fail, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkGit(): Promise<CheckResult> {
try {
const version = execSync('git --version', { encoding: 'utf-8' }).trim()
const v = version.replace('git version ', '')
pass('Git', v)
return { name: 'Git', ok: true, version: v }
} catch {
fail('Git', 'not installed')
const platform = detectPlatform()
const instructions =
platform === 'macos'
? [
'Git is included with Xcode CLI Tools. Install them first:',
'',
' xcode-select --install'
]
: platform === 'windows'
? [
'Download Git from: https://git-scm.com/download/win',
'Run the installer with default settings.'
]
: [
'Install git using your package manager:',
'',
' sudo apt install git # Debian/Ubuntu',
' sudo dnf install git # Fedora',
' sudo pacman -S git # Arch'
]
info(instructions)
return { name: 'Git', ok: false, installInstructions: instructions }
}
}

View File

@@ -0,0 +1,59 @@
import { execSync } from 'node:child_process'
import { detectPlatform } from './platform'
import { pass, fail, warn, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkNode(): Promise<CheckResult> {
try {
const version = execSync('node --version', { encoding: 'utf-8' }).trim()
const major = parseInt(version.replace('v', '').split('.')[0])
if (major < 20) {
warn('Node.js', `${version} (need v20+)`)
const instructions = [
`Node.js ${version} is too old. You need v20 or later.`,
'',
'Update via nvm:',
' nvm install 20',
' nvm use 20'
]
info(instructions)
return {
name: 'Node.js',
ok: false,
version,
installInstructions: instructions
}
}
pass('Node.js', version)
return { name: 'Node.js', ok: true, version }
} catch {
fail('Node.js', 'not installed')
const platform = detectPlatform()
const instructions =
platform === 'macos'
? [
'Install Node.js via nvm (recommended):',
'',
' curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash',
'',
'Close and reopen Terminal, then run:',
'',
' nvm install 20'
]
: platform === 'windows'
? [
'Download Node.js from: https://nodejs.org/',
'Choose the LTS version (v20+).',
'Run the installer with default settings.'
]
: [
'Install Node.js via nvm:',
'',
' curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash',
' source ~/.bashrc',
' nvm install 20'
]
info(instructions)
return { name: 'Node.js', ok: false, installInstructions: instructions }
}
}

View File

@@ -0,0 +1,24 @@
import os from 'node:os'
import { pass } from '../ui/logger'
import type { CheckResult } from './types'
type Platform = 'macos' | 'windows' | 'linux'
export function detectPlatform(): Platform {
switch (os.platform()) {
case 'darwin':
return 'macos'
case 'win32':
return 'windows'
default:
return 'linux'
}
}
export function checkPlatform(): CheckResult {
const p = os.platform()
const arch = os.arch()
const release = os.release()
pass('Operating System', `${p} ${release} (${arch})`)
return { name: 'Operating System', ok: true, version: `${p} ${arch}` }
}

View File

@@ -0,0 +1,51 @@
import { execSync } from 'node:child_process'
import { pass, fail, warn, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkPlaywright(): Promise<CheckResult> {
try {
// Check if chromium browser is installed
execSync('pnpm exec playwright install --dry-run chromium', {
encoding: 'utf-8',
stdio: 'pipe'
})
pass('Playwright browsers', 'chromium installed')
return { name: 'Playwright browsers', ok: true, version: 'chromium' }
} catch {
// Browser might not be installed, try to check another way
try {
const result = execSync('pnpm exec playwright --version', {
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
warn('Playwright', `${result} (browsers may need installing)`)
const instructions = [
'Playwright browsers need to be installed:',
'',
' pnpm exec playwright install chromium --with-deps',
'',
'This downloads ~200MB. Please wait...'
]
info(instructions)
return {
name: 'Playwright browsers',
ok: false,
installInstructions: instructions
}
} catch {
fail('Playwright', 'not installed')
const instructions = [
'Playwright is a project dependency. Run:',
'',
' pnpm install',
' pnpm exec playwright install chromium --with-deps'
]
info(instructions)
return {
name: 'Playwright',
ok: false,
installInstructions: instructions
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { execSync } from 'node:child_process'
import { pass, fail, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkPnpm(): Promise<CheckResult> {
try {
const version = execSync('pnpm --version', { encoding: 'utf-8' }).trim()
pass('pnpm', version)
return { name: 'pnpm', ok: true, version }
} catch {
fail('pnpm', 'not installed')
const instructions = [
'Install pnpm via corepack (comes with Node.js):',
'',
' corepack enable',
' corepack prepare pnpm@10.17.1 --activate',
'',
'Or install directly:',
'',
' npm install -g pnpm'
]
info(instructions)
return { name: 'pnpm', ok: false, installInstructions: instructions }
}
}

View File

@@ -0,0 +1,39 @@
import { execSync } from 'node:child_process'
import { detectPlatform } from './platform'
import { pass, fail, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkPython(): Promise<CheckResult> {
for (const cmd of ['python3', 'python']) {
try {
const version = execSync(`${cmd} --version`, { encoding: 'utf-8' }).trim()
pass('Python', version)
return { name: 'Python', ok: true, version }
} catch {
continue
}
}
fail('Python 3', 'not installed')
const platform = detectPlatform()
const instructions =
platform === 'macos'
? [
'Python 3 is needed for the ComfyUI backend.',
'',
' brew install python3',
'',
'Or download from: https://www.python.org/downloads/'
]
: platform === 'windows'
? [
'Download Python from: https://www.python.org/downloads/',
'Check "Add Python to PATH" during install.'
]
: [
' sudo apt install python3 # Debian/Ubuntu',
' sudo dnf install python3 # Fedora'
]
info(instructions)
return { name: 'Python 3', ok: false, installInstructions: instructions }
}

View File

@@ -0,0 +1,7 @@
export interface CheckResult {
name: string
ok: boolean
version?: string
optional?: boolean
installInstructions?: string[]
}

View File

@@ -0,0 +1,34 @@
import { execSync } from 'node:child_process'
import { detectPlatform } from './platform'
import { pass, fail, info } from '../ui/logger'
import type { CheckResult } from './types'
export async function checkXcode(): Promise<CheckResult> {
if (detectPlatform() !== 'macos') {
return { name: 'Xcode CLI Tools', ok: true, version: 'n/a (not macOS)' }
}
try {
const path = execSync('xcode-select -p', { encoding: 'utf-8' }).trim()
pass('Xcode CLI Tools', path)
return { name: 'Xcode CLI Tools', ok: true, version: path }
} catch {
fail('Xcode CLI Tools', 'not installed')
const instructions = [
'Xcode Command Line Tools are required for git and build tools.',
'',
'To install, run this in Terminal:',
'',
' xcode-select --install',
'',
'A popup will appear — click "Install" and wait (~5 min).',
'When done, come back here and press Enter.'
]
info(instructions)
return {
name: 'Xcode CLI Tools',
ok: false,
installInstructions: instructions
}
}
}

View File

@@ -0,0 +1,39 @@
import { checkPlatform } from '../checks/platform'
import { checkXcode } from '../checks/xcode'
import { checkGit } from '../checks/git'
import { checkNode } from '../checks/node'
import { checkPnpm } from '../checks/pnpm'
import { checkPython } from '../checks/python'
import { checkPlaywright } from '../checks/playwright'
import { checkGh } from '../checks/gh'
import { checkDevServer } from '../checks/devServer'
import { checkBackend } from '../checks/backend'
import { header } from '../ui/logger'
import type { CheckResult } from '../checks/types'
export async function runChecks(): Promise<{
results: CheckResult[]
allPassed: boolean
}> {
header('Environment Check')
const results: CheckResult[] = []
// System checks (sequential — each depends on prior)
results.push(checkPlatform())
results.push(await checkXcode())
results.push(await checkGit())
results.push(await checkNode())
results.push(await checkPnpm())
results.push(await checkPython())
results.push(await checkPlaywright())
results.push(await checkGh())
header('Services Check')
results.push(await checkBackend())
results.push(await checkDevServer())
const requiredFailed = results.filter((r) => !r.ok && !r.optional)
return { results, allPassed: requiredFailed.length === 0 }
}

View File

@@ -0,0 +1,38 @@
import pc from 'picocolors'
import { listWorkflows, findProjectRoot } from '../recorder/runner'
import { header } from '../ui/logger'
export async function runList(): Promise<void> {
header('Available Workflows')
const projectRoot = findProjectRoot()
const workflows = listWorkflows(projectRoot)
if (workflows.length === 0) {
console.log(pc.dim(' No workflow assets found in browser_tests/assets/'))
return
}
console.log(
pc.dim(` Found ${workflows.length} workflows in browser_tests/assets/:\n`)
)
let currentDir = ''
for (const wf of workflows) {
const parts = wf.split('/')
if (parts.length > 1) {
const dir = parts.slice(0, -1).join('/')
if (dir !== currentDir) {
currentDir = dir
console.log(pc.bold(` ${dir}/`))
}
console.log(` ${pc.cyan(parts[parts.length - 1])}`)
} else {
if (currentDir !== '') {
currentDir = ''
console.log()
}
console.log(` ${pc.cyan(wf)}`)
}
}
}

View File

@@ -0,0 +1,257 @@
import { createInterface } from 'node:readline'
import { spawnSync } from 'node:child_process'
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
text,
select,
multiselect,
confirm,
isCancel,
cancel,
spinner
} from '@clack/prompts'
import pc from 'picocolors'
import { runChecks } from './check'
import {
runRecording,
findProjectRoot,
listWorkflows
} from '../recorder/runner'
import { transform, formatTransformSummary } from '../transform/engine'
import { stepHeader } from '../ui/steps'
import { pass, fail, info, blank, box } from '../ui/logger'
import { checkGhAvailable, createPr } from '../pr/gh'
import { printManualInstructions } from '../pr/manual'
import { copyToClipboard } from '../pr/clipboard'
function toSlug(description: string): string {
return description
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function readMultiline(): Promise<string> {
return new Promise((resolve) => {
const lines: string[] = []
const rl = createInterface({ input: process.stdin })
rl.on('line', (line) => lines.push(line))
rl.on('close', () => resolve(lines.join('\n')))
})
}
export async function runRecord(): Promise<void> {
// ── Step 1: Environment Check ──────────────────────────────
stepHeader(1, 7, 'Environment Check')
const { allPassed } = await runChecks()
if (!allPassed) {
blank()
fail('Some required checks failed. Fix the issues above.')
process.exit(1)
}
// ── Step 2: Project Setup ──────────────────────────────────
stepHeader(2, 7, 'Project Setup')
let projectRoot: string
try {
projectRoot = findProjectRoot()
} catch (err) {
fail(err instanceof Error ? err.message : 'Could not find project root')
process.exit(1)
}
const s = spinner()
s.start('Installing dependencies...')
const install = spawnSync('pnpm', ['install'], {
cwd: projectRoot,
stdio: 'pipe'
})
if (install.status !== 0) {
s.stop('Dependency installation failed')
fail('pnpm install failed', install.stderr?.toString() ?? '')
process.exit(1)
}
s.stop('Dependencies installed')
pass('Project ready', projectRoot)
// ── Step 3: Backend & Dev Server ───────────────────────────
stepHeader(3, 7, 'Backend & Dev Server')
pass('Services checked in Step 1')
// ── Step 4: Configure Your Test ────────────────────────────
stepHeader(4, 7, 'Configure Your Test')
const description = await text({
message: 'What are you testing?',
placeholder: 'e.g., adding a KSampler node and queuing'
})
if (isCancel(description)) {
cancel('Operation cancelled')
process.exit(0)
}
let slug = toSlug(description)
const filenameOk = await confirm({
message: `Generated filename: ${slug}.spec.ts — looks good?`
})
if (isCancel(filenameOk)) {
cancel('Operation cancelled')
process.exit(0)
}
if (!filenameOk) {
const customName = await text({
message: 'Enter a custom filename (without .spec.ts):',
placeholder: slug
})
if (isCancel(customName)) {
cancel('Operation cancelled')
process.exit(0)
}
slug = toSlug(customName)
}
const selectedTags = await multiselect({
message: 'Select tags for this test:',
options: [
{ value: '@canvas', label: '@canvas' },
{ value: '@widget', label: '@widget' },
{ value: '@sidebar', label: '@sidebar' },
{ value: '@smoke', label: '@smoke' },
{ value: '@mobile', label: '@mobile' },
{ value: '@screenshot', label: '@screenshot' }
],
initialValues: ['@canvas']
})
if (isCancel(selectedTags)) {
cancel('Operation cancelled')
process.exit(0)
}
const workflows = listWorkflows(projectRoot)
const workflowOptions: {
value: string
label: string
hint?: string
}[] = [
{ value: '', label: '(empty canvas)', hint: 'start fresh' },
...workflows.map((wf) => ({ value: wf, label: wf }))
]
const selectedWorkflow = await select({
message: 'Start with a pre-loaded workflow?',
options: workflowOptions
})
if (isCancel(selectedWorkflow)) {
cancel('Operation cancelled')
process.exit(0)
}
// ── Step 5: Record ─────────────────────────────────────────
stepHeader(5, 7, 'Record')
const result = await runRecording({
testName: slug,
workflow: selectedWorkflow || undefined,
projectRoot
})
if (!result.success) {
fail('Recording failed', result.error)
process.exit(1)
}
// ── Step 6: Paste & Transform ──────────────────────────────
stepHeader(6, 7, 'Paste & Transform')
info([
'Copy the generated code from the Playwright Inspector.',
'',
'Paste your code below, then press ' +
pc.bold('Ctrl+D') +
' (Mac/Linux) or ' +
pc.bold('Ctrl+Z') +
' (Windows) when done:'
])
blank()
const pastedCode = await readMultiline()
if (!pastedCode.trim()) {
blank()
info([
'No code pasted. You can transform later with:',
pc.cyan(` comfy-test transform <file>`)
])
process.exit(0)
}
const transformResult = transform(pastedCode, {
testName: slug,
tags: selectedTags as string[]
})
blank()
const summary = formatTransformSummary(transformResult)
for (const line of summary) {
console.log(` ${line}`)
}
const testsDir = join(projectRoot, 'browser_tests', 'tests')
mkdirSync(testsDir, { recursive: true })
const outputPath = join(testsDir, `${slug}.spec.ts`)
writeFileSync(outputPath, transformResult.code)
blank()
pass('Test saved', outputPath)
// ── Step 7: Finalize ───────────────────────────────────────
stepHeader(7, 7, 'Finalize')
box([
'Run your test:',
pc.cyan(` pnpm exec playwright test ${slug} --headed`),
'',
'Review in UI mode:',
pc.cyan(' pnpm exec playwright test --ui')
])
blank()
const wantPr = await confirm({
message: 'Create a Pull Request now?'
})
if (isCancel(wantPr)) {
cancel('Operation cancelled')
process.exit(0)
}
if (wantPr) {
const gh = await checkGhAvailable()
if (gh.available && gh.authenticated) {
await createPr({
testFilePath: outputPath,
testName: slug,
description: description as string
})
} else {
const fileContents = readFileSync(outputPath, 'utf-8')
const copied = await copyToClipboard(fileContents)
if (!copied.ok) {
info([
'Could not copy to clipboard. File contents are at:',
pc.cyan(outputPath)
])
}
const relativePath = `browser_tests/tests/${slug}.spec.ts`
printManualInstructions({
testFilePath: outputPath,
testName: slug,
relativePath
})
}
} else {
blank()
info(['You can create a PR later.', pc.dim(`Test file: ${outputPath}`)])
}
}

View File

@@ -0,0 +1,55 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { basename } from 'node:path'
import pc from 'picocolors'
import { transform, formatTransformSummary } from '../transform/engine'
import { header } from '../ui/logger'
export async function runTransform(
filePath: string,
options: {
testName?: string
tags?: string[]
output?: string
} = {}
): Promise<void> {
header('Transform Codegen → Conventions')
// Read input
let rawCode: string
try {
rawCode = readFileSync(filePath, 'utf-8')
} catch {
console.log(pc.red(` Could not read file: ${filePath}`))
process.exit(1)
}
console.log(pc.dim(` Input: ${filePath}`))
console.log()
// Transform
const inferredName = basename(filePath).replace(/\.raw\.spec\.ts$/, '')
const testName = options.testName ?? (inferredName || 'test')
const tags = options.tags ?? ['@canvas']
const result = transform(rawCode, { testName, tags })
// Print summary
console.log(' Transforms applied:')
for (const line of formatTransformSummary(result)) {
console.log(` ${line}`)
}
console.log()
// Write output
const outputPath =
options.output ?? filePath.replace(/\.raw\.spec\.ts$/, '.spec.ts')
if (!options.output && outputPath === filePath) {
console.log(
pc.red(
' Refusing to overwrite input file. Pass --output or use a *.raw.spec.ts input.'
)
)
process.exit(1)
}
writeFileSync(outputPath, result.code)
console.log(pc.green(` ✅ Saved: ${outputPath}`))
}

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { intro, outro } from '@clack/prompts'
import pc from 'picocolors'
const args = process.argv.slice(2)
const command = args[0]
intro(pc.bgCyan(pc.black(' 🎭 ComfyUI Test Recorder ')))
switch (command) {
case 'record': {
const { runRecord } = await import('./commands/record')
await runRecord()
break
}
case 'transform': {
const filePath = args[1]
if (!filePath) {
console.log(pc.red(' Usage: comfy-test transform <file>'))
process.exit(1)
}
const { runTransform } = await import('./commands/transform')
await runTransform(filePath, {
testName: args[2],
tags: args.slice(3)
})
break
}
case 'check': {
const { runChecks } = await import('./commands/check')
const { allPassed } = await runChecks()
if (!allPassed) {
console.log()
console.log(
pc.red(
' Some required checks failed. Fix the issues above and try again.'
)
)
process.exitCode = 1
}
break
}
case 'list': {
const { runList } = await import('./commands/list')
await runList()
break
}
default:
console.log(`
Usage: comfy-test <command>
Commands:
record Record a new browser test interactively
transform Transform raw codegen output to conventions
check Check environment prerequisites
list List available test workflows
Options:
--help Show help
`)
break
}
outro(pc.dim('https://github.com/Comfy-Org/ComfyUI_frontend'))

View File

@@ -0,0 +1,44 @@
import { spawnSync } from 'node:child_process'
import { detectPlatform } from '../checks/platform'
type ClipboardResult = { ok: true } | { ok: false; reason: string }
export async function copyToClipboard(
content: string
): Promise<ClipboardResult> {
const platform = detectPlatform()
let cmd: string
let args: string[]
if (platform === 'macos') {
cmd = 'pbcopy'
args = []
} else if (platform === 'windows') {
cmd = 'clip.exe'
args = []
} else {
// Linux: try xclip first, fall back to xsel
const xclip = spawnSync('xclip', ['-selection', 'clipboard'], {
input: content,
stdio: 'pipe'
})
if (xclip.status === 0) return { ok: true }
const xsel = spawnSync('xsel', ['--clipboard', '--input'], {
input: content,
stdio: 'pipe'
})
return xsel.status === 0
? { ok: true }
: { ok: false, reason: 'xclip/xsel unavailable or failed' }
}
const result = spawnSync(cmd, args, {
input: content,
stdio: 'pipe'
})
return result.status === 0
? { ok: true }
: { ok: false, reason: `${cmd} failed` }
}

View File

@@ -0,0 +1,100 @@
import { execSync, spawnSync } from 'node:child_process'
import { pass, fail } from '../ui/logger'
interface PrOptions {
testFilePath: string
testName: string
description: string
branchName?: string
}
interface PrResult {
success: boolean
url?: string
error?: string
}
export async function checkGhAvailable(): Promise<{
available: boolean
authenticated: boolean
}> {
try {
execSync('gh --version', { stdio: 'pipe' })
} catch {
return { available: false, authenticated: false }
}
try {
execSync('gh auth status', {
stdio: 'pipe',
encoding: 'utf-8'
})
return { available: true, authenticated: true }
} catch {
return { available: true, authenticated: false }
}
}
export async function createPr(options: PrOptions): Promise<PrResult> {
const branchName = options.branchName ?? `test/${options.testName}`
const commitMsg = `test: add ${options.testName} e2e test\n\n${options.description}`
const prTitle = `test: add ${options.testName} e2e test`
const prBody =
`${options.description}\n\n---\n\n` + 'Recorded with `comfy-test record`'
const checkout = spawnSync('git', ['checkout', '-b', branchName], {
encoding: 'utf-8',
stdio: 'pipe'
})
if (checkout.status !== 0) {
fail('Branch creation failed', checkout.stderr.trim())
return {
success: false,
error: checkout.stderr.trim()
}
}
pass('Created branch', branchName)
const add = spawnSync('git', ['add', options.testFilePath], {
encoding: 'utf-8',
stdio: 'pipe'
})
if (add.status !== 0) {
fail('Git add failed', add.stderr.trim())
return { success: false, error: add.stderr.trim() }
}
const commit = spawnSync('git', ['commit', '-m', commitMsg], {
encoding: 'utf-8',
stdio: 'pipe'
})
if (commit.status !== 0) {
fail('Git commit failed', commit.stderr.trim())
return { success: false, error: commit.stderr.trim() }
}
pass('Committed test file')
const push = spawnSync('git', ['push', '-u', 'origin', branchName], {
encoding: 'utf-8',
stdio: 'pipe'
})
if (push.status !== 0) {
fail('Git push failed', push.stderr.trim())
return { success: false, error: push.stderr.trim() }
}
pass('Pushed branch', branchName)
const pr = spawnSync(
'gh',
['pr', 'create', '--title', prTitle, '--body', prBody, '--fill'],
{ encoding: 'utf-8', stdio: 'pipe' }
)
if (pr.status !== 0) {
fail('PR creation failed', pr.stderr.trim())
return { success: false, error: pr.stderr.trim() }
}
const url = pr.stdout.trim()
pass('Pull request created', url)
return { success: true, url }
}

View File

@@ -0,0 +1,29 @@
import { info } from '../ui/logger'
interface ManualPrOptions {
testFilePath: string
testName: string
relativePath: string
repoUrl?: string
}
const DEFAULT_REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
export function printManualInstructions(options: ManualPrOptions): void {
const repo = options.repoUrl ?? DEFAULT_REPO
info([
'Create a PR manually:',
'',
` 1. Go to: ${repo}`,
' 2. Click "Add file" → "Create new file"',
` 3. Set path to: ${options.relativePath}`,
' 4. Paste the contents (copied to your clipboard ✅)',
` 5. Write commit message: "test: add ${options.testName} e2e test"`,
' 6. Select "Create a new branch" → click "Propose new file"',
' 7. Click "Create pull request"',
'',
'Or, ask an AI agent: "Create a PR with this test file"',
`(The file is at ${options.testFilePath})`
])
}

View File

@@ -0,0 +1,160 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { dirname, join, parse } from 'node:path'
import pc from 'picocolors'
import { generateRecordingTemplate, cleanupRecordingTemplate } from './template'
import { box } from '../ui/logger'
interface RunnerOptions {
testName: string
workflow?: string
projectRoot: string
}
interface RecordingResult {
success: boolean
rawOutputPath?: string
error?: string
}
/**
* Find the project root by looking for playwright.config.ts
*/
export function findProjectRoot(): string {
let dir = process.cwd()
const { root } = parse(dir)
while (true) {
if (existsSync(join(dir, 'playwright.config.ts'))) {
return dir
}
if (dir === root) break
dir = dirname(dir)
}
throw new Error(
'Could not find project root (no playwright.config.ts found). ' +
'Run this command from the ComfyUI_frontend directory.'
)
}
/**
* List available workflow assets.
*/
export function listWorkflows(projectRoot: string): string[] {
const assetsDir = join(projectRoot, 'browser_tests', 'assets')
const results: string[] = []
function walk(dir: string, prefix: string) {
try {
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry)
const relPath = prefix ? `${prefix}/${entry}` : entry
const stat = statSync(fullPath)
if (stat.isDirectory()) {
walk(fullPath, relPath)
} else if (entry.endsWith('.json')) {
results.push(relPath.replace(/\.json$/, ''))
}
}
} catch {
// Directory might not exist
}
}
walk(assetsDir, '')
return results.sort()
}
/**
* Run the recording session.
*
* 1. Generate the temporary test file with page.pause()
* 2. Run it in headed mode — this opens the Playwright Inspector
* 3. User records their actions
* 4. User closes the browser
* 5. We save the output as *.raw.spec.ts
*/
export async function runRecording(
options: RunnerOptions
): Promise<RecordingResult> {
const browserTestsDir = join(options.projectRoot, 'browser_tests')
// Generate the recording template
const tempFile = generateRecordingTemplate(
{ testName: options.testName, workflow: options.workflow },
browserTestsDir
)
console.log()
box([
'A browser window will open.',
'',
'👉 Perform your test actions:',
' • Click, type, drag — everything is recorded',
' • Use toolbar buttons to add assertions',
' • When done, close the browser window',
'',
'The Playwright Inspector shows generated code.'
])
console.log()
try {
// Run the test in headed mode with PWDEBUG to force inspector
const result = spawnSync(
'pnpm',
[
'exec',
'playwright',
'test',
'_recording-session',
'--headed',
'--project=chromium',
'--timeout=0'
],
{
cwd: options.projectRoot,
stdio: 'inherit',
env: {
...process.env,
PWDEBUG: '1',
PLAYWRIGHT_LOCAL: '1'
}
}
)
if (result.error) {
return {
success: false,
error: `Failed to spawn pnpm: ${result.error.message}`
}
}
if (result.status !== 0) {
return {
success: false,
error: `Playwright exited with status ${result.status}`
}
}
console.log()
console.log(pc.green(' ✅ Recording session complete.'))
console.log()
// Save a placeholder raw file — the user pastes codegen output here
// or the record command handles prompting for it
const rawOutputPath = join(
browserTestsDir,
'tests',
`${options.testName}.raw.spec.ts`
)
return { success: true, rawOutputPath }
} catch (err) {
return {
success: false,
error: `Recording failed: ${err instanceof Error ? err.message : String(err)}`
}
} finally {
// Always clean up the temp recording file
cleanupRecordingTemplate(browserTestsDir)
}
}

View File

@@ -0,0 +1,72 @@
import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
import { join, dirname } from 'node:path'
interface TemplateOptions {
workflow?: string
testName: string
}
/**
* Generates a temporary test file that uses page.pause() to open
* the Playwright Inspector with codegen controls.
*
* The test file:
* 1. Uses comfyPageFixture to get full fixture context
* 2. Optionally loads a workflow
* 3. Calls page.pause() to open the Inspector
*/
export function generateRecordingTemplate(
options: TemplateOptions,
browserTestsDir: string
): string {
const filePath = join(browserTestsDir, 'tests', `_recording-session.spec.ts`)
const workflowLine = options.workflow
? ` // Load the selected workflow\n await comfyPage.workflow.loadWorkflow('${options.workflow.replace(/'/g, "\\'")}')\n await comfyPage.nextFrame()\n`
: ''
const safeName = options.testName.replace(/[`$\\]/g, '')
const code = `/**
* Auto-generated recording session.
* This file is temporary — it will be deleted after recording.
*
* DO NOT COMMIT THIS FILE.
*/
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test('recording: ${safeName}', async ({ comfyPage }) => {
${workflowLine}
// ┌────────────────────────────────────────────────────────┐
// │ The Playwright Inspector will open. │
// │ │
// │ 1. Click the Record button (red circle) to start │
// │ 2. Perform your test actions in the browser │
// │ 3. Use toolbar buttons to add assertions │
// │ 4. Click Stop when done │
// │ 5. Copy the generated code from the Inspector │
// │ 6. Close the browser window │
// └────────────────────────────────────────────────────────┘
await comfyPage.page.pause()
})
`
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, code)
return filePath
}
/**
* Clean up the temporary recording file.
*/
export function cleanupRecordingTemplate(browserTestsDir: string): void {
const filePath = join(browserTestsDir, 'tests', '_recording-session.spec.ts')
try {
unlinkSync(filePath)
} catch {
// File might already be deleted
}
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { transform, formatTransformSummary } from './engine'
describe('transform', () => {
const rawCodegenOutput = `import { test, expect } from '@playwright/test'
test('my test', async ({ page }) => {
await page.goto('http://localhost:8188')
await page.locator('canvas').click()
await page.waitForTimeout(1000)
await page.getByPlaceholder('Search Nodes...').fill('KSampler')
})`
it('applies all applicable regex rules', () => {
const result = transform(rawCodegenOutput, {
testName: 'canvas-test',
tags: ['@canvas']
})
expect(result.code).toContain('comfyPageFixture as test')
expect(result.code).toContain('async ({ comfyPage })')
expect(result.code).not.toContain('page.goto')
expect(result.code).toContain('comfyPage.canvas')
expect(result.code).toContain('comfyPage.nextFrame()')
expect(result.code).toContain('comfyPage.searchBox.input')
})
it('wraps test in describe block', () => {
const result = transform(rawCodegenOutput, {
testName: 'canvas-test',
tags: ['@canvas']
})
expect(result.code).toContain('test.describe(')
expect(result.code).toContain('"canvas test"')
})
it('tracks applied rules', () => {
const result = transform(rawCodegenOutput, { testName: 'test' })
const ruleNames = result.appliedRules.map((r) => r.name)
expect(ruleNames).toContain('replace-test-import')
expect(ruleNames).toContain('replace-page-destructure')
expect(ruleNames).toContain('remove-goto')
expect(ruleNames).toContain('replace-canvas-locator')
expect(ruleNames).toContain('replace-waitForTimeout')
expect(ruleNames).toContain('wrap-in-describe')
})
it('warns about remaining pixel coordinates', () => {
const input = `import { test } from '@playwright/test'
test('pos test', async ({ page }) => {
await page.click({ position: { x: 100, y: 200 } })
})`
const result = transform(input)
expect(result.warnings).toContainEqual(
expect.stringContaining('pixel coordinates')
)
})
it('uses default testName and tags when not provided', () => {
const result = transform(rawCodegenOutput)
expect(result.code).toContain('"unnamed test"')
expect(result.code).toContain('"@canvas"')
})
it('collapses triple blank lines', () => {
const input = `import { test } from '@playwright/test'
test('x', async ({ page }) => {})`
const result = transform(input)
expect(result.code).not.toMatch(/\n{3,}/)
})
it('returns code ending with a single newline', () => {
const result = transform(rawCodegenOutput)
expect(result.code).toMatch(/[^\n]\n$/)
})
})
describe('formatTransformSummary', () => {
it('formats applied rules with checkmarks', () => {
const lines = formatTransformSummary({
code: '',
appliedRules: [{ name: 'test-rule', description: 'Did a thing' }],
warnings: []
})
expect(lines).toEqual(['✅ Did a thing'])
})
it('formats warnings', () => {
const lines = formatTransformSummary({
code: '',
appliedRules: [],
warnings: ['Something is wrong']
})
expect(lines).toEqual(['⚠️ Something is wrong'])
})
it('returns empty array when no rules or warnings', () => {
const lines = formatTransformSummary({
code: '',
appliedRules: [],
warnings: []
})
expect(lines).toEqual([])
})
})

View File

@@ -0,0 +1,92 @@
import { transformRules, structuralTransforms } from './rules'
interface TransformResult {
code: string
appliedRules: { name: string; description: string }[]
warnings: string[]
}
/**
* Transform raw Playwright codegen output into ComfyUI conventions.
*/
export function transform(
rawCode: string,
options: {
testName?: string
tags?: string[]
} = {}
): TransformResult {
const testName = options.testName ?? 'unnamed-test'
const tags = options.tags ?? ['@canvas']
let code = rawCode
const appliedRules: { name: string; description: string }[] = []
const warnings: string[] = []
// Phase 1: Apply regex-based rules
for (const rule of transformRules) {
const before = code
if (typeof rule.replacement === 'string') {
code = code.replace(rule.pattern, rule.replacement)
} else {
code = code.replace(
rule.pattern,
rule.replacement as (...args: string[]) => string
)
}
if (code !== before) {
appliedRules.push({ name: rule.name, description: rule.description })
}
}
// Phase 2: Clean up empty lines from removed statements
code = code.replace(/\n{3,}/g, '\n\n')
// Phase 3: Apply structural transforms
for (const transform of structuralTransforms) {
const before = code
code = transform.apply(code, testName, tags)
if (code !== before) {
appliedRules.push({
name: transform.name,
description: transform.description
})
}
}
// Phase 4: Check for remaining issues and warn
if (code.includes('waitForTimeout')) {
warnings.push(
'Still contains waitForTimeout — replace with comfyPage.nextFrame() or retrying assertions'
)
}
if (
code.includes("from '@playwright/test'") ||
code.includes('from "@playwright/test"')
) {
warnings.push(
'Still imports from @playwright/test — should use ../fixtures/ComfyPage'
)
}
if (/position:\s*\{\s*x:\s*\d+,\s*y:\s*\d+/.test(code)) {
warnings.push(
'Contains pixel coordinates — consider replacing with node references (comfyPage.nodeOps.*) where possible'
)
}
return { code: code.trim() + '\n', appliedRules, warnings }
}
/**
* Get a human-readable summary of what was transformed.
*/
export function formatTransformSummary(result: TransformResult): string[] {
const lines: string[] = []
for (const rule of result.appliedRules) {
lines.push(`${rule.description}`)
}
for (const warning of result.warnings) {
lines.push(`⚠️ ${warning}`)
}
return lines
}

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import { transformRules, structuralTransforms } from './rules'
describe('transformRules', () => {
function applyRule(ruleName: string, input: string): string {
const rule = transformRules.find((r) => r.name === ruleName)
if (!rule) throw new Error(`Rule not found: ${ruleName}`)
if (typeof rule.replacement === 'string') {
return input.replace(rule.pattern, rule.replacement)
}
return input.replace(
rule.pattern,
rule.replacement as (...args: string[]) => string
)
}
describe('import transforms', () => {
it('replaces { test, expect } from @playwright/test', () => {
const input = `import { test, expect } from '@playwright/test'`
const result = applyRule('replace-test-import', input)
expect(result).toContain('comfyPageFixture as test')
expect(result).toContain('comfyExpect as expect')
expect(result).toContain("from '../fixtures/ComfyPage'")
})
it('replaces { expect, test } (reversed order)', () => {
const input = `import { expect, test } from '@playwright/test'`
const result = applyRule('replace-test-import', input)
expect(result).toContain('comfyPageFixture as test')
})
it('replaces test-only import', () => {
const input = `import { test } from '@playwright/test'`
const result = applyRule('replace-test-only-import', input)
expect(result).toContain('comfyPageFixture as test')
expect(result).not.toContain('expect')
})
it('replaces expect-only import', () => {
const input = `import { expect } from '@playwright/test'`
const result = applyRule('replace-expect-only-import', input)
expect(result).toContain('comfyExpect as expect')
expect(result).not.toContain('comfyPageFixture')
})
})
describe('fixture transforms', () => {
it('replaces { page } with { comfyPage }', () => {
const input = `test('my test', async ({ page }) => {`
const result = applyRule('replace-page-destructure', input)
expect(result).toContain('async ({ comfyPage })')
expect(result).not.toContain('{ page }')
})
})
describe('locator transforms', () => {
it('removes page.goto calls', () => {
const input = ` await page.goto('http://localhost:8188')\n await page.click('button')`
const result = applyRule('remove-goto', input)
expect(result).not.toContain('page.goto')
expect(result).toContain('page.click')
})
it('replaces page.locator("canvas")', () => {
const input = `await page.locator('canvas').click()`
const result = applyRule('replace-canvas-locator', input)
expect(result).toBe('await comfyPage.canvas.click()')
})
it('replaces search box placeholder', () => {
const input = `page.getByPlaceholder('Search Nodes...')`
const result = applyRule('replace-search-placeholder', input)
expect(result).toBe('comfyPage.searchBox.input')
})
it('replaces bare page. references with comfyPage.page.', () => {
const input = `await page.click('button')`
const result = applyRule('replace-bare-page', input)
expect(result).toBe(`await comfyPage.page.click('button')`)
})
it('does not replace comfyPage.page. (no double-replace)', () => {
const input = `await comfyPage.page.click('button')`
const result = applyRule('replace-bare-page', input)
expect(result).toBe(input)
})
})
describe('wait transforms', () => {
it('replaces waitForTimeout with nextFrame', () => {
const input = `await page.waitForTimeout(1000);`
const result = applyRule('replace-waitForTimeout', input)
expect(result).toBe('await comfyPage.nextFrame()')
})
it('handles waitForTimeout without semicolon', () => {
const input = `await page.waitForTimeout(500)`
const result = applyRule('replace-waitForTimeout', input)
expect(result).toBe('await comfyPage.nextFrame()')
})
})
})
describe('structuralTransforms', () => {
const wrapInDescribe = structuralTransforms.find(
(t) => t.name === 'wrap-in-describe'
)!
it('wraps a test in test.describe with tags', () => {
const input = `import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test('does something', async ({ comfyPage }) => {
await comfyPage.canvas.click()
})`
const result = wrapInDescribe.apply(input, 'my-test', ['@canvas'])
expect(result).toContain('test.describe(')
expect(result).toContain('"my test"')
expect(result).toContain('"@canvas"')
expect(result).toContain('test.afterEach')
expect(result).toContain('resetView')
})
it('skips wrapping when test.describe already exists', () => {
const input = `test.describe('existing', () => {
test('inner', async ({ comfyPage }) => {})
})`
const result = wrapInDescribe.apply(input, 'test', ['@canvas'])
expect(result).toBe(input)
})
it('converts hyphens and underscores to spaces in describe name', () => {
const input = `import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test('x', async ({ comfyPage }) => {})`
const result = wrapInDescribe.apply(input, 'my_test-name', ['@canvas'])
expect(result).toContain('"my test name"')
})
})

View File

@@ -0,0 +1,127 @@
interface TransformRule {
name: string
description: string
pattern: RegExp
replacement: string | ((match: string, ...groups: string[]) => string)
category: 'import' | 'fixture' | 'locator' | 'wait' | 'structure' | 'cleanup'
}
export const transformRules: TransformRule[] = [
// === Import transforms ===
{
name: 'replace-test-import',
description: 'Use comfyPageFixture instead of @playwright/test',
pattern:
/import\s*\{\s*(?:test\s*,?\s*expect|expect\s*,?\s*test)\s*\}\s*from\s*['"]@playwright\/test['"]/,
replacement: `import {\n comfyPageFixture as test,\n comfyExpect as expect\n} from '../fixtures/ComfyPage'`,
category: 'import'
},
{
name: 'replace-test-only-import',
description: 'Use comfyPageFixture when only test is imported',
pattern: /import\s*\{\s*test\s*\}\s*from\s*['"]@playwright\/test['"]/,
replacement: `import { comfyPageFixture as test } from '../fixtures/ComfyPage'`,
category: 'import'
},
{
name: 'replace-expect-only-import',
description: 'Use comfyExpect when only expect is imported',
pattern: /import\s*\{\s*expect\s*\}\s*from\s*['"]@playwright\/test['"]/,
replacement: `import { comfyExpect as expect } from '../fixtures/ComfyPage'`,
category: 'import'
},
// === Fixture transforms ===
{
name: 'replace-page-destructure',
description: 'Use comfyPage fixture instead of page',
pattern: /async\s*\(\s*\{\s*page\s*(?:,\s*\w+\s*)*\}\s*\)/g,
replacement: 'async ({ comfyPage })',
category: 'fixture'
},
// === Remove page.goto ===
{
name: 'remove-goto',
description: 'Remove page.goto — fixture handles navigation',
pattern: /^\s*await\s+page\.goto\s*\([^)]*\)\s*;?\s*$/gm,
replacement: '',
category: 'locator'
},
// === Locator transforms ===
{
name: 'replace-canvas-locator',
description: 'Use comfyPage.canvas instead of page.locator("canvas")',
pattern: /page\.locator\(\s*['"]canvas['"]\s*\)/g,
replacement: 'comfyPage.canvas',
category: 'locator'
},
{
name: 'replace-search-placeholder',
description: 'Use comfyPage.searchBox for search input',
pattern: /page\.getByPlaceholder\(\s*['"]Search\s+Nodes\.{0,3}['"]\s*\)/g,
replacement: 'comfyPage.searchBox.input',
category: 'locator'
},
{
name: 'replace-bare-page',
description: 'Replace bare page references with comfyPage.page',
pattern: /(?<![\w.])page\./g,
replacement: 'comfyPage.page.',
category: 'locator'
},
// === Wait transforms ===
{
name: 'replace-waitForTimeout',
description: 'Use comfyPage.nextFrame() instead of arbitrary waits',
pattern:
/await\s+(?:comfyPage\.)?page\.waitForTimeout\s*\(\s*\d+\s*\)\s*;?/g,
replacement: 'await comfyPage.nextFrame()',
category: 'wait'
}
]
/**
* Rules that need structural changes (not just regex replacement).
* These are applied by the engine after regex rules.
*/
interface StructuralTransform {
name: string
description: string
apply: (code: string, testName: string, tags: string[]) => string
}
export const structuralTransforms: StructuralTransform[] = [
{
name: 'wrap-in-describe',
description: 'Wrap test in test.describe with tags and afterEach',
apply: (code: string, testName: string, tags: string[]) => {
// If already has test.describe, skip
if (code.includes('test.describe')) return code
const tagStr = tags.map((t) => JSON.stringify(t)).join(', ')
const descName = JSON.stringify(
testName.replace(/[-_]/g, ' ').replace(/\.spec\.ts$/, '')
)
// Find the test() call and wrap it
const testMatch = code.match(
/^(import[\s\S]*?\n\n?)(test(?:\.(?:only|skip|fixme))?\s*\([\s\S]*)$/m
)
if (!testMatch) return code
const imports = testMatch[1]
const testBody = testMatch[2]
return `${imports}test.describe(${descName}, { tag: [${tagStr}] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
${testBody.replace(/^(?=.)/gm, ' ').trimStart()}
})\n`
}
}
]

View File

@@ -0,0 +1,43 @@
import pc from 'picocolors'
export function pass(label: string, detail?: string) {
const d = detail ? pc.dim(` ${detail}`) : ''
console.log(` ${pc.green('✅')} ${label}${d}`)
}
export function fail(label: string, detail?: string) {
const d = detail ? pc.dim(` ${detail}`) : ''
console.log(` ${pc.red('❌')} ${label}${d}`)
}
export function warn(label: string, detail?: string) {
const d = detail ? pc.dim(` ${detail}`) : ''
console.log(` ${pc.yellow('⚠️')} ${label}${d}`)
}
export function info(lines: string[]) {
for (const line of lines) {
console.log(` ${pc.dim('┃')} ${line}`)
}
}
export function blank() {
console.log()
}
export function header(text: string) {
console.log()
console.log(pc.bold(` ━━━ ${text} ━━━`))
console.log()
}
export function box(lines: string[]) {
if (lines.length === 0) return
const maxLen = Math.max(...lines.map((l) => l.length))
const border = '─'.repeat(maxLen + 4)
console.log(`${border}`)
for (const line of lines) {
console.log(`${line.padEnd(maxLen + 2)}`)
}
console.log(`${border}`)
}

View File

@@ -0,0 +1,7 @@
import pc from 'picocolors'
export function stepHeader(current: number, total: number, label: string) {
console.log()
console.log(pc.bold(` ━━━ Step ${current} of ${total}: ${label} ━━━`))
console.log()
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -652,7 +652,8 @@ export default defineConfig({
include: [
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'packages/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
'scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'tools/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
coverage: {
provider: 'v8',