mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-20 14:53:21 +00:00
Compare commits
32 Commits
batch-disp
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f297c24b | ||
|
|
c6de167fe9 | ||
|
|
6b02aab81a | ||
|
|
7fd35c2f82 | ||
|
|
0e5d788688 | ||
|
|
2d0fbe3822 | ||
|
|
478b39cdc5 | ||
|
|
61a6b402ae | ||
|
|
fb98dfd736 | ||
|
|
cee63169ba | ||
|
|
92561d2f5c | ||
|
|
1f80ca56e5 | ||
|
|
3e3625bf95 | ||
|
|
a1aeb5254f | ||
|
|
ef44acf16d | ||
|
|
fded789d9f | ||
|
|
2c01be33bd | ||
|
|
f1a058ea4f | ||
|
|
b9a4f86713 | ||
|
|
618c32416f | ||
|
|
fd02ebcf26 | ||
|
|
b2f96cc3c0 | ||
|
|
58135fed67 | ||
|
|
2f56d6727d | ||
|
|
e5a967f3a7 | ||
|
|
ff06060689 | ||
|
|
0d9171e8a3 | ||
|
|
5e570c85a8 | ||
|
|
622f9a8238 | ||
|
|
a3893a593d | ||
|
|
deba72e7a0 | ||
|
|
3db0eac353 |
125
.claude/agents/playwright-test-generator.md
Normal file
125
.claude/agents/playwright-test-generator.md
Normal 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
|
||||
91
.claude/agents/playwright-test-healer.md
Normal file
91
.claude/agents/playwright-test-healer.md
Normal 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
|
||||
89
.claude/agents/playwright-test-planner.md
Normal file
89
.claude/agents/playwright-test-planner.md
Normal 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
|
||||
95
.claude/skills/codegen-transform/SKILL.md
Normal file
95
.claude/skills/codegen-transform/SKILL.md
Normal 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
1
.gitignore
vendored
@@ -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
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-test": {
|
||||
"command": "pnpm",
|
||||
"args": ["dlx", "@playwright/mcp@0.0.68"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
3
browser_tests/specs/README.md
Normal file
3
browser_tests/specs/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Specs
|
||||
|
||||
This is a directory for test plans.
|
||||
@@ -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
|
||||
|
||||
87
browser_tests/tests/load3d/gizmoControls.spec.ts
Normal file
87
browser_tests/tests/load3d/gizmoControls.spec.ts
Normal 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')
|
||||
}
|
||||
)
|
||||
})
|
||||
26
browser_tests/tests/seed.spec.ts
Normal file
26
browser_tests/tests/seed.spec.ts
Normal 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()
|
||||
})
|
||||
@@ -31,6 +31,9 @@ const config: KnipConfig = {
|
||||
},
|
||||
'apps/website': {
|
||||
entry: ['src/scripts/**/*.ts']
|
||||
},
|
||||
'tools/test-recorder': {
|
||||
project: ['src/**/*.ts']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
|
||||
@@ -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
97
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- apps/**
|
||||
- packages/**
|
||||
- tools/**
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
|
||||
167
scripts/patch-playwright-agents.js
Normal file
167
scripts/patch-playwright-agents.js
Normal 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(', ')}`
|
||||
)
|
||||
}
|
||||
28
scripts/update-playwright-agents.sh
Executable file
28
scripts/update-playwright-agents.sh
Executable 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/"
|
||||
@@ -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'
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
155
src/components/load3d/controls/GizmoControls.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
122
src/components/load3d/controls/GizmoControls.vue
Normal file
122
src/components/load3d/controls/GizmoControls.vue
Normal 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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
368
src/extensions/core/load3d/GizmoManager.test.ts
Normal file
368
src/extensions/core/load3d/GizmoManager.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
229
src/extensions/core/load3d/GizmoManager.ts
Normal file
229
src/extensions/core/load3d/GizmoManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
164
src/extensions/core/load3d/Load3DConfiguration.test.ts
Normal file
164
src/extensions/core/load3d/Load3DConfiguration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
269
src/extensions/core/load3d/Load3d.test.ts
Normal file
269
src/extensions/core/load3d/Load3d.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
32
tools/test-recorder/README.md
Normal file
32
tools/test-recorder/README.md
Normal 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
|
||||
```
|
||||
23
tools/test-recorder/package.json
Normal file
23
tools/test-recorder/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
33
tools/test-recorder/src/checks/backend.ts
Normal file
33
tools/test-recorder/src/checks/backend.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
25
tools/test-recorder/src/checks/devServer.ts
Normal file
25
tools/test-recorder/src/checks/devServer.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
55
tools/test-recorder/src/checks/gh.ts
Normal file
55
tools/test-recorder/src/checks/gh.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
tools/test-recorder/src/checks/git.ts
Normal file
37
tools/test-recorder/src/checks/git.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
59
tools/test-recorder/src/checks/node.ts
Normal file
59
tools/test-recorder/src/checks/node.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
24
tools/test-recorder/src/checks/platform.ts
Normal file
24
tools/test-recorder/src/checks/platform.ts
Normal 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}` }
|
||||
}
|
||||
51
tools/test-recorder/src/checks/playwright.ts
Normal file
51
tools/test-recorder/src/checks/playwright.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
tools/test-recorder/src/checks/pnpm.ts
Normal file
25
tools/test-recorder/src/checks/pnpm.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
39
tools/test-recorder/src/checks/python.ts
Normal file
39
tools/test-recorder/src/checks/python.ts
Normal 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 }
|
||||
}
|
||||
7
tools/test-recorder/src/checks/types.ts
Normal file
7
tools/test-recorder/src/checks/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface CheckResult {
|
||||
name: string
|
||||
ok: boolean
|
||||
version?: string
|
||||
optional?: boolean
|
||||
installInstructions?: string[]
|
||||
}
|
||||
34
tools/test-recorder/src/checks/xcode.ts
Normal file
34
tools/test-recorder/src/checks/xcode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tools/test-recorder/src/commands/check.ts
Normal file
39
tools/test-recorder/src/commands/check.ts
Normal 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 }
|
||||
}
|
||||
38
tools/test-recorder/src/commands/list.ts
Normal file
38
tools/test-recorder/src/commands/list.ts
Normal 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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
257
tools/test-recorder/src/commands/record.ts
Normal file
257
tools/test-recorder/src/commands/record.ts
Normal 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}`)])
|
||||
}
|
||||
}
|
||||
55
tools/test-recorder/src/commands/transform.ts
Normal file
55
tools/test-recorder/src/commands/transform.ts
Normal 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}`))
|
||||
}
|
||||
65
tools/test-recorder/src/index.ts
Normal file
65
tools/test-recorder/src/index.ts
Normal 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'))
|
||||
44
tools/test-recorder/src/pr/clipboard.ts
Normal file
44
tools/test-recorder/src/pr/clipboard.ts
Normal 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` }
|
||||
}
|
||||
100
tools/test-recorder/src/pr/gh.ts
Normal file
100
tools/test-recorder/src/pr/gh.ts
Normal 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 }
|
||||
}
|
||||
29
tools/test-recorder/src/pr/manual.ts
Normal file
29
tools/test-recorder/src/pr/manual.ts
Normal 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})`
|
||||
])
|
||||
}
|
||||
160
tools/test-recorder/src/recorder/runner.ts
Normal file
160
tools/test-recorder/src/recorder/runner.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
72
tools/test-recorder/src/recorder/template.ts
Normal file
72
tools/test-recorder/src/recorder/template.ts
Normal 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
|
||||
}
|
||||
}
|
||||
109
tools/test-recorder/src/transform/engine.test.ts
Normal file
109
tools/test-recorder/src/transform/engine.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
92
tools/test-recorder/src/transform/engine.ts
Normal file
92
tools/test-recorder/src/transform/engine.ts
Normal 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
|
||||
}
|
||||
141
tools/test-recorder/src/transform/rules.test.ts
Normal file
141
tools/test-recorder/src/transform/rules.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
127
tools/test-recorder/src/transform/rules.ts
Normal file
127
tools/test-recorder/src/transform/rules.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
]
|
||||
43
tools/test-recorder/src/ui/logger.ts
Normal file
43
tools/test-recorder/src/ui/logger.ts
Normal 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}┘`)
|
||||
}
|
||||
7
tools/test-recorder/src/ui/steps.ts
Normal file
7
tools/test-recorder/src/ui/steps.ts
Normal 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()
|
||||
}
|
||||
15
tools/test-recorder/tsconfig.json
Normal file
15
tools/test-recorder/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user