mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
48 Commits
test/cover
...
v1.44.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
389ff8ba49 | ||
|
|
bb74ec94de | ||
|
|
e7640d414b | ||
|
|
c168c37c94 | ||
|
|
089051824c | ||
|
|
517da289f6 | ||
|
|
98c327b3c6 | ||
|
|
fc2a4e82cf | ||
|
|
e48d33e4c0 | ||
|
|
967f1eb562 | ||
|
|
8b83559402 | ||
|
|
bc11b5ff5e | ||
|
|
8c1ea7ae64 | ||
|
|
69e68847d9 | ||
|
|
fad9cf0db7 | ||
|
|
d532fcf779 | ||
|
|
52e73f2697 | ||
|
|
c4043637d6 | ||
|
|
9d61b4df06 | ||
|
|
963a7bf178 | ||
|
|
f001bc9af3 | ||
|
|
88faaf3d86 | ||
|
|
42ff7b6c62 | ||
|
|
f404887c96 | ||
|
|
b37c66539b | ||
|
|
95e9a9405b | ||
|
|
00974d6339 | ||
|
|
878ffb70cc | ||
|
|
a441364a55 | ||
|
|
8dcadd6fe1 | ||
|
|
3a05a37323 | ||
|
|
6cfa5fdb1f | ||
|
|
3ea75e1c48 | ||
|
|
9c0edd0048 | ||
|
|
7ee667c1d1 | ||
|
|
2b010ac8b3 | ||
|
|
b4d209b5f6 | ||
|
|
9a70676c61 | ||
|
|
9ce4c18eb1 | ||
|
|
56aec1878a | ||
|
|
502a02213a | ||
|
|
f1ea3b02a6 | ||
|
|
b2bba78ce0 | ||
|
|
3d14bfb09c | ||
|
|
3340b77908 | ||
|
|
0ad85087ea | ||
|
|
4c892341e4 | ||
|
|
eb8f8b75b5 |
@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
|
||||
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
|
||||
2
.github/workflows/ci-perf-report.yaml
vendored
2
.github/workflows/ci-perf-report.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/ci-tests-e2e.yaml
vendored
4
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/ci-website-e2e.yaml
vendored
4
.github/workflows/ci-website-e2e.yaml
vendored
@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, website/*]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
background-color: var(--color-coral-700);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
background-color: var(--color-coral-600);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
background-color: var(--color-coral-500);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
|
||||
@@ -4,12 +4,12 @@ const translations = {
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
'zh-CN': '视觉 AI 的\n专业控制'
|
||||
'zh-CN': '视觉 AI 的\n最强可控性'
|
||||
},
|
||||
'hero.subtitle': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
@@ -20,11 +20,11 @@ const translations = {
|
||||
},
|
||||
'showcase.subtitle2': {
|
||||
en: 'Start from a community template or build from scratch.',
|
||||
'zh-CN': '从社区模板开始,或从零构建。'
|
||||
'zh-CN': '从工作流模板开始,或从零构建。'
|
||||
},
|
||||
'showcase.feature1.title': {
|
||||
en: 'Full Control with Nodes',
|
||||
'zh-CN': '节点式完全控制'
|
||||
'zh-CN': '节点带来的可控性'
|
||||
},
|
||||
'showcase.feature1.description': {
|
||||
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
|
||||
@@ -49,8 +49,8 @@ const translations = {
|
||||
'zh-CN':
|
||||
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
|
||||
},
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运作' },
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.label': {
|
||||
@@ -83,8 +83,7 @@ const translations = {
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
|
||||
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
|
||||
},
|
||||
'useCase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
@@ -164,7 +163,7 @@ const translations = {
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版特性'
|
||||
'zh-CN': '查看本地版属性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -176,7 +175,7 @@ const translations = {
|
||||
},
|
||||
'products.cloud.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端特性'
|
||||
'zh-CN': '查看云端属性'
|
||||
},
|
||||
'products.api.title': {
|
||||
en: 'Comfy\nAPI',
|
||||
@@ -188,7 +187,7 @@ const translations = {
|
||||
},
|
||||
'products.api.cta': {
|
||||
en: 'SEE API FEATURES',
|
||||
'zh-CN': '查看 API 特性'
|
||||
'zh-CN': '查看 API 属性'
|
||||
},
|
||||
'products.enterprise.title': {
|
||||
en: 'Comfy\nEnterprise',
|
||||
@@ -200,7 +199,7 @@ const translations = {
|
||||
},
|
||||
'products.enterprise.cta': {
|
||||
en: 'SEE ENTERPRISE FEATURES',
|
||||
'zh-CN': '查看企业版特性'
|
||||
'zh-CN': '查看企业版属性'
|
||||
},
|
||||
|
||||
// CaseStudySpotlightSection
|
||||
@@ -1215,7 +1214,7 @@ const translations = {
|
||||
'pricing.included.feature4.description': {
|
||||
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
|
||||
'zh-CN':
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro)。'
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro)。'
|
||||
},
|
||||
'pricing.included.feature5.title': {
|
||||
en: 'Add more credits anytime',
|
||||
@@ -1245,12 +1244,12 @@ const translations = {
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作节点'
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -15,11 +15,15 @@ browser_tests/
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ ├── components/ - Page object classes (locators, user interactions)
|
||||
│ │ ├── Actionbar.ts
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── ManageGroupNode.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ └── Topbar.ts
|
||||
│ │ ├── Templates.ts
|
||||
│ │ ├── Topbar.ts
|
||||
│ │ └── ...
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
@@ -28,17 +32,36 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Pure utility functions (no page dependency)
|
||||
├── helpers/ - Test-specific utilities
|
||||
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
|
||||
│ ├── builderTestUtils.ts
|
||||
│ ├── clipboardSpy.ts
|
||||
│ ├── fitToView.ts
|
||||
│ ├── perfReporter.ts
|
||||
│ └── ...
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
|
||||
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
|
||||
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
|
||||
|
||||
### Placement Rule
|
||||
|
||||
When adding a new file, use this decision tree:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[New file in browser_tests/fixtures/] --> B{Has any code?}
|
||||
B -- No, JSON/data only --> D[fixtures/data/]
|
||||
B -- Yes --> C{Is it a class?}
|
||||
C -- No, exported functions --> U[fixtures/utils/]
|
||||
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
|
||||
E -- Yes --> P[fixtures/components/]
|
||||
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
|
||||
```
|
||||
|
||||
## Page Object Locator Style
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ DISABLE_VUE_PLUGINS=true
|
||||
# Test against dev server (recommended) or backend directly
|
||||
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
|
||||
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
|
||||
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
|
||||
|
||||
# Path to ComfyUI for backing up user data/settings before tests
|
||||
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
@@ -139,12 +140,9 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
|
||||
- **ComfyMouse**: Helper for precise mouse operations on the canvas
|
||||
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
|
||||
- `actionbar.ts`: Interact with the action bar
|
||||
- `manageGroupNode.ts`: Group node management operations
|
||||
- `templates.ts`: Template workflows operations
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
|
||||
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
|
||||
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
|
||||
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
|
||||
74
browser_tests/assets/widgets/painter_with_input.json
Normal file
74
browser_tests/assets/widgets/painter_with_input.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [450, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
@@ -22,6 +22,7 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
ModelLibrarySidebarTab,
|
||||
@@ -54,11 +55,13 @@ class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +140,7 @@ class ComfyMenu {
|
||||
|
||||
export class ComfyPage {
|
||||
public readonly url: string
|
||||
public readonly apiUrl: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly selectionToolbox: Locator
|
||||
@@ -159,6 +163,7 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly templatesDialog: TemplatesDialog
|
||||
public readonly titleEditor: TitleEditor
|
||||
public readonly mediaLightbox: MediaLightbox
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly appMode: AppModeHelper
|
||||
@@ -195,6 +200,7 @@ export class ComfyPage {
|
||||
public readonly request: APIRequestContext
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
@@ -204,13 +210,14 @@ export class ComfyPage {
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.templatesDialog = new TemplatesDialog(page)
|
||||
this.titleEditor = new TitleEditor(page)
|
||||
this.mediaLightbox = new MediaLightbox(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.appMode = new AppModeHelper(this)
|
||||
@@ -236,7 +243,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
const res = await this.request.get(`${this.apiUrl}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
@@ -250,7 +257,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
@@ -262,7 +269,7 @@ export class ComfyPage {
|
||||
|
||||
async setupSettings(settings: Record<string, unknown>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
`${this.apiUrl}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
|
||||
@@ -1,29 +1,104 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
readonly filterSearch: Locator
|
||||
readonly results: Locator
|
||||
readonly filterOptions: Locator
|
||||
readonly filterChips: Locator
|
||||
readonly noResults: Locator
|
||||
readonly nodeIdBadge: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
this.dialog = page.getByRole('search')
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
this.input = this.dialog.getByRole('combobox')
|
||||
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
|
||||
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
|
||||
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
|
||||
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
|
||||
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
categoryButton(categoryId: string): Locator {
|
||||
return this.dialog.getByTestId(`category-${categoryId}`)
|
||||
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
|
||||
}
|
||||
|
||||
filterBarButton(name: string): Locator {
|
||||
return this.dialog.getByRole('button', { name })
|
||||
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
|
||||
rootCategoryButton(id: RootCategoryId): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
|
||||
}
|
||||
|
||||
async reload(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
/** Top filter-bar input/output type popover trigger. */
|
||||
typeFilterButton(key: 'input' | 'output'): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
|
||||
}
|
||||
|
||||
async applyTypeFilter(
|
||||
key: 'input' | 'output',
|
||||
typeName: string
|
||||
): Promise<void> {
|
||||
const trigger = this.typeFilterButton(key)
|
||||
await trigger.click()
|
||||
await this.filterOptions.first().waitFor({ state: 'visible' })
|
||||
await this.filterSearch.fill(typeName)
|
||||
await this.filterOptions.filter({ hasText: typeName }).first().click()
|
||||
// The popover does not auto-close on selection — toggle the trigger.
|
||||
await trigger.click()
|
||||
await this.filterOptions.first().waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async removeFilterChip(index = 0): Promise<void> {
|
||||
await this.filterChips
|
||||
.nth(index)
|
||||
.getByTestId(searchBoxV2.chipDelete)
|
||||
.click()
|
||||
}
|
||||
|
||||
async toggle(): Promise<void> {
|
||||
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (await this.input.isVisible()) return
|
||||
await this.toggle()
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async openByDoubleClickCanvas(): Promise<void> {
|
||||
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
|
||||
// does not intercept; coords target a viewport spot that is on the canvas
|
||||
// and clear of both the side toolbar and any default-graph nodes.
|
||||
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
}
|
||||
|
||||
async ensureV2Search(): Promise<void> {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
}
|
||||
|
||||
async setup(): Promise<void> {
|
||||
await this.ensureV2Search()
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import type { Locator, Page } from '@playwright/test'
|
||||
export class ContextMenu {
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
}
|
||||
|
||||
@@ -39,7 +41,10 @@ export class ContextMenu {
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible
|
||||
const litegraphContextVisible = await this.litegraphContextMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible || litegraphContextVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -71,7 +76,8 @@ export class ContextMenu {
|
||||
async waitForHidden(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.primeVueMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' })
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphContextMenu.waitFor({ state: 'hidden' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
72
browser_tests/fixtures/components/PublishDialog.ts
Normal file
72
browser_tests/fixtures/components/PublishDialog.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PublishDialog extends BaseDialog {
|
||||
readonly nav: Locator
|
||||
readonly footer: Locator
|
||||
readonly savePrompt: Locator
|
||||
readonly describeStep: Locator
|
||||
readonly finishStep: Locator
|
||||
readonly profilePrompt: Locator
|
||||
readonly gateFlow: Locator
|
||||
readonly nameInput: Locator
|
||||
readonly descriptionTextarea: Locator
|
||||
readonly tagsInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly nextButton: Locator
|
||||
readonly publishButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.publish.dialog)
|
||||
this.nav = this.root.getByTestId(TestIds.publish.nav)
|
||||
this.footer = this.root.getByTestId(TestIds.publish.footer)
|
||||
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
|
||||
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
|
||||
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
|
||||
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
|
||||
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
|
||||
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
|
||||
this.descriptionTextarea = this.describeStep.locator('textarea')
|
||||
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
|
||||
this.backButton = this.footer.getByRole('button', { name: 'Back' })
|
||||
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
|
||||
this.publishButton = this.footer.getByRole('button', {
|
||||
name: 'Publish to ComfyHub'
|
||||
})
|
||||
}
|
||||
|
||||
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
|
||||
// dev and production, rather than clicking through the UI.
|
||||
async open(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.dialog.showPublishDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
tagSuggestion(name: string): Locator {
|
||||
return this.describeStep.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
navStep(label: string): Locator {
|
||||
return this.nav.getByRole('button', { name: label })
|
||||
}
|
||||
|
||||
currentNavStep(): Locator {
|
||||
return this.nav.locator('[aria-current="step"]')
|
||||
}
|
||||
|
||||
async goNext(): Promise<void> {
|
||||
await this.nextButton.click()
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
await this.backButton.click()
|
||||
}
|
||||
|
||||
async goToStep(label: string): Promise<void> {
|
||||
await this.navStep(label).click()
|
||||
}
|
||||
}
|
||||
33
browser_tests/fixtures/components/TitleEditor.ts
Normal file
33
browser_tests/fixtures/components/TitleEditor.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* The node/group title-editing input. Rendered in three scopes: the canvas
|
||||
* overlay (page-wide), the properties panel, and the Vue node itself.
|
||||
*/
|
||||
export class TitleEditor {
|
||||
public readonly input: Locator
|
||||
|
||||
constructor(scope: Page | Locator) {
|
||||
this.input = scope.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await this.input.fill(title)
|
||||
await this.input.press('Enter')
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
await this.input.press('Escape')
|
||||
}
|
||||
|
||||
async expectVisible(): Promise<void> {
|
||||
await expect(this.input).toBeVisible()
|
||||
}
|
||||
|
||||
async expectHidden(): Promise<void> {
|
||||
await expect(this.input).toBeHidden()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportResponse,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobsListRoutePattern = '**/api/jobs?*'
|
||||
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetExportRoutePattern = '**/api/assets/export'
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
@@ -158,12 +168,23 @@ function getExecutionDuration(job: RawJobListItem): number {
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private cloudAssetsResponse: ListAssetsResponse | null = null
|
||||
private assetExportRequests: CreateAssetExportData['body'][] = []
|
||||
private assetExportResponse: CreateAssetExportResponse | null = null
|
||||
private importedFiles: string[] = []
|
||||
private readonly jobDetailRouteHandlers = new Map<
|
||||
string,
|
||||
(route: Route) => Promise<void>
|
||||
>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -240,6 +261,82 @@ export class AssetsHelper {
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
|
||||
this.cloudAssetsResponse = response
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cloudAssetsRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.cloudAssetsResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyCloudAssets(): Promise<void> {
|
||||
await this.mockCloudAssets({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
})
|
||||
}
|
||||
|
||||
async captureAssetExportRequests(
|
||||
response: CreateAssetExportResponse = {
|
||||
task_id: 'asset-export-task',
|
||||
status: 'created'
|
||||
}
|
||||
): Promise<CreateAssetExportData['body'][]> {
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = response
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
this.assetExportRouteHandler = async (route: Route) => {
|
||||
this.assetExportRequests.push(
|
||||
route.request().postDataJSON() as CreateAssetExportData['body']
|
||||
)
|
||||
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.assetExportResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
|
||||
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
|
||||
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
|
||||
|
||||
if (existingHandler) {
|
||||
await this.page.unroute(pattern, existingHandler)
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(detail)
|
||||
})
|
||||
}
|
||||
|
||||
this.jobDetailRouteHandlers.set(pattern, handler)
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
@@ -295,6 +392,9 @@ export class AssetsHelper {
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.cloudAssetsResponse = null
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = null
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
@@ -302,6 +402,22 @@ export class AssetsHelper {
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetsListRoutePattern,
|
||||
this.cloudAssetsRouteHandler
|
||||
)
|
||||
this.cloudAssetsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetExportRoutePattern,
|
||||
this.assetExportRouteHandler
|
||||
)
|
||||
this.assetExportRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
@@ -317,5 +433,10 @@ export class AssetsHelper {
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
|
||||
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.jobDetailRouteHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class CanvasHelper {
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
@@ -150,6 +150,28 @@ export class CanvasHelper {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async getOffset(): Promise<[number, number]> {
|
||||
return this.page.evaluate(
|
||||
() => [...window.app!.canvas.ds.offset] as [number, number]
|
||||
)
|
||||
}
|
||||
|
||||
async getNodeTitleHeight(): Promise<number> {
|
||||
return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
|
||||
* coordinates.
|
||||
*/
|
||||
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.down('Shift')
|
||||
await this.dragAndDrop(from, to)
|
||||
await this.page.keyboard.up('Shift')
|
||||
await this.page.keyboard.up('Control')
|
||||
}
|
||||
|
||||
async convertOffsetToCanvas(
|
||||
pos: [number, number]
|
||||
): Promise<[number, number]> {
|
||||
@@ -242,11 +264,39 @@ export class CanvasHelper {
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
async disconnectEdge(): Promise<void> {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
)
|
||||
async disconnectEdge(
|
||||
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
|
||||
): Promise<void> {
|
||||
const { modifiers = [] } = options
|
||||
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||
try {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
)
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
}
|
||||
|
||||
async middleClick(position: Position): Promise<void> {
|
||||
await this.mouseClickAt(position, { button: 'middle' })
|
||||
}
|
||||
|
||||
async dblclickGroupTitle(title: string): Promise<void> {
|
||||
const clientPos = await this.page.evaluate((targetTitle) => {
|
||||
const groups = window.app!.canvas.graph?.groups ?? []
|
||||
const group = groups.find(
|
||||
(g: { title: string }) => g.title === targetTitle
|
||||
)
|
||||
if (!group) return null
|
||||
const cx = group.pos[0] + group.size[0] / 2
|
||||
const cy = group.pos[1] + group.titleHeight / 2
|
||||
return window.app!.canvasPosToClientPos([cx, cy])
|
||||
}, title)
|
||||
if (!clientPos) throw new Error(`Group "${title}" not found`)
|
||||
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename } from 'path'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
|
||||
@@ -13,7 +13,11 @@ import type { Page } from '@playwright/test'
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
private readonly appUrl: string
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
@@ -34,7 +38,7 @@ export class CloudAuthHelper {
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
await this.page.goto(`${this.appUrl}/api/users`)
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
@@ -39,6 +43,48 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to the graph by type.
|
||||
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
|
||||
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
|
||||
* true and position is provided, a synthetic MouseEvent is created as the
|
||||
* dragEvent.
|
||||
* @param position - When ghost is true, client coordinates for the ghost
|
||||
* placement dragEvent. Otherwise, world coordinates assigned to node.pos.
|
||||
*/
|
||||
async addNode(
|
||||
type: string,
|
||||
options?: Omit<GraphAddOptions, 'dragEvent'>,
|
||||
position?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, pos]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && pos) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
clientX: pos.x,
|
||||
clientY: pos.y
|
||||
})
|
||||
} else if (pos) {
|
||||
node.pos = [pos.x, pos.y]
|
||||
}
|
||||
window.app!.graph.add(node, addOpts as GraphAddOptions)
|
||||
return node.id
|
||||
},
|
||||
[type, options ?? {}, position ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
231
browser_tests/fixtures/helpers/PublishApiHelper.ts
Normal file
231
browser_tests/fixtures/helpers/PublishApiHelper.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
AssetInfo,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
WorkflowPublishInfo
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const DEFAULT_PROFILE: HubProfile = {
|
||||
username: 'testuser',
|
||||
display_name: 'Test User',
|
||||
description: 'A test creator',
|
||||
avatar_url: undefined
|
||||
}
|
||||
|
||||
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
|
||||
{ name: 'anime', display_name: 'anime', type: 'tag' },
|
||||
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
|
||||
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
|
||||
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
|
||||
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
|
||||
]
|
||||
|
||||
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
|
||||
workflow_id: 'test-workflow-id-456',
|
||||
share_id: 'test-share-id-123',
|
||||
publish_time: new Date().toISOString(),
|
||||
listed: true,
|
||||
assets: []
|
||||
}
|
||||
|
||||
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
upload_url: 'https://mock-s3.example.com/upload',
|
||||
public_url: 'https://mock-s3.example.com/asset.png',
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
export class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockProfile(profile: HubProfile | null): Promise<void> {
|
||||
await this.addRoute('**/hub/profiles/me', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (profile === null) {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(profile)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockTagLabels(
|
||||
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
|
||||
): Promise<void> {
|
||||
const response: HubLabelListResponse = { labels }
|
||||
await this.addRoute('**/hub/labels**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishStatus(
|
||||
status: 'unpublished' | WorkflowPublishInfo
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
if (status === 'unpublished') {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(status)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
|
||||
const response: ShareableAssetsResponse = { assets }
|
||||
await this.addRoute('**/assets/from-workflow', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflow(
|
||||
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockPublishWorkflowError(
|
||||
statusCode = 500,
|
||||
message = 'Failed to publish workflow'
|
||||
): Promise<void> {
|
||||
await this.removeRoutes('**/hub/workflows')
|
||||
await this.addRoute('**/hub/workflows', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: statusCode,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockUploadUrl(
|
||||
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
|
||||
): Promise<void> {
|
||||
await this.addRoute('**/hub/assets/upload-url', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async setupDefaultMocks(options?: {
|
||||
hasProfile?: boolean
|
||||
hasPrivateAssets?: boolean
|
||||
}): Promise<void> {
|
||||
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
|
||||
|
||||
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
|
||||
await this.mockTagLabels()
|
||||
await this.mockPublishStatus('unpublished')
|
||||
await this.mockShareableAssets(
|
||||
hasPrivateAssets
|
||||
? [
|
||||
{
|
||||
id: 'asset-1',
|
||||
name: 'my_model.safetensors',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: true,
|
||||
public: false,
|
||||
in_library: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
await this.mockPublishWorkflow()
|
||||
await this.mockUploadUrl()
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
}
|
||||
|
||||
private async addRoute(
|
||||
pattern: string,
|
||||
handler: (route: Route) => Promise<void>
|
||||
): Promise<void> {
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
private async removeRoutes(pattern: string): Promise<void> {
|
||||
const handlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern === pattern
|
||||
)
|
||||
for (const { handler } of handlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = this.routeHandlers.filter(
|
||||
(route) => route.pattern !== pattern
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const publishFixture = comfyPageFixture.extend<{
|
||||
publishApi: PublishApiHelper
|
||||
publishDialog: PublishDialog
|
||||
}>({
|
||||
publishApi: async ({ comfyPage }, use) => {
|
||||
const helper = new PublishApiHelper(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.cleanup()
|
||||
},
|
||||
publishDialog: async ({ comfyPage }, use) => {
|
||||
await use(new PublishDialog(comfyPage.page))
|
||||
}
|
||||
})
|
||||
@@ -59,6 +59,9 @@ export const TestIds = {
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
@@ -83,7 +86,11 @@ export const TestIds = {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
subscribeButton: 'topbar-subscribe-button',
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -209,6 +216,18 @@ export const TestIds = {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
publish: {
|
||||
dialog: 'publish-dialog',
|
||||
savePrompt: 'publish-save-prompt',
|
||||
describeStep: 'publish-describe-step',
|
||||
finishStep: 'publish-finish-step',
|
||||
footer: 'publish-footer',
|
||||
profilePrompt: 'publish-profile-prompt',
|
||||
nav: 'publish-nav',
|
||||
gateFlow: 'publish-gate-flow',
|
||||
nameInput: 'publish-name-input',
|
||||
tagsInput: 'publish-tags-input'
|
||||
},
|
||||
loading: {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
@@ -234,6 +253,17 @@ export const TestIds = {
|
||||
batchCounter: 'batch-counter',
|
||||
batchNext: 'batch-next',
|
||||
batchPrev: 'batch-prev'
|
||||
},
|
||||
searchBoxV2: {
|
||||
resultItem: 'result-item',
|
||||
filterOption: 'filter-option',
|
||||
filterChip: 'filter-chip',
|
||||
chipDelete: 'chip-delete',
|
||||
noResults: 'no-results',
|
||||
nodeIdBadge: 'node-id-badge',
|
||||
category: (id: string) => `category-${id}`,
|
||||
rootCategory: (id: string) => `search-category-${id}`,
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
import { comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
@@ -169,6 +170,36 @@ class NodeSlotReference {
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
}
|
||||
|
||||
async getLink(): Promise<SerialisableLLink | null> {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const node = graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const linkId =
|
||||
type === 'input'
|
||||
? node.inputs[index].link
|
||||
: (node.outputs[index].links ?? [])[0]
|
||||
if (linkId == null) return null
|
||||
const link =
|
||||
graph.links instanceof Map
|
||||
? graph.links.get(linkId)
|
||||
: graph.links[linkId]
|
||||
if (!link) return null
|
||||
return {
|
||||
id: link.id,
|
||||
origin_id: link.origin_id,
|
||||
origin_slot: link.origin_slot,
|
||||
target_id: link.target_id,
|
||||
target_slot: link.target_slot,
|
||||
type: link.type,
|
||||
parentId: link.parentId
|
||||
}
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeWidgetReference {
|
||||
@@ -326,6 +357,23 @@ export class NodeReference {
|
||||
const nodeSize = await this.getSize()
|
||||
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
|
||||
}
|
||||
async dragBy(
|
||||
delta: Position,
|
||||
options?: {
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const titlePos = await this.getTitlePosition()
|
||||
const target = { x: titlePos.x + delta.x, y: titlePos.y + delta.y }
|
||||
const modifiers = options?.modifiers ?? []
|
||||
const keyboard = this.comfyPage.page.keyboard
|
||||
for (const mod of modifiers) await keyboard.down(mod)
|
||||
try {
|
||||
await this.comfyPage.canvasOps.dragAndDrop(titlePos, target)
|
||||
} finally {
|
||||
for (const mod of modifiers) await keyboard.up(mod)
|
||||
}
|
||||
}
|
||||
async isPinned() {
|
||||
return !!(await this.getFlags()).pinned
|
||||
}
|
||||
|
||||
@@ -56,7 +56,13 @@ export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync('test-results', { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!entries.length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
public readonly header: Locator
|
||||
public readonly title: Locator
|
||||
public readonly titleInput: Locator
|
||||
public readonly titleEditor: TitleEditor
|
||||
public readonly body: Locator
|
||||
public readonly pinIndicator: Locator
|
||||
public readonly collapseButton: Locator
|
||||
@@ -16,7 +17,7 @@ export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
this.title = locator.getByTestId('node-title')
|
||||
this.titleInput = locator.getByTestId('node-title-input')
|
||||
this.titleEditor = new TitleEditor(locator)
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
@@ -30,17 +31,8 @@ export class VueNodeFixture {
|
||||
|
||||
async setTitle(value: string): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
async cancelTitleEdit(): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.press('Escape')
|
||||
await this.titleEditor.expectVisible()
|
||||
await this.titleEditor.setTitle(value)
|
||||
}
|
||||
|
||||
async toggleCollapse(): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig'
|
||||
import { writePerfReport } from '@e2e/helpers/perfReporter'
|
||||
import { writePerfReport } from '@e2e/fixtures/utils/perfReporter'
|
||||
import { restorePath } from '@e2e/utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
140
browser_tests/tests/actionBarButtons.spec.ts
Normal file
140
browser_tests/tests/actionBarButtons.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const ICON_CLASS = 'icon-[lucide--star]'
|
||||
const BUTTON_LABEL = 'Test Action'
|
||||
const BUTTON_TOOLTIP = 'Test action tooltip'
|
||||
|
||||
async function registerTestButton(
|
||||
page: Page,
|
||||
opts: {
|
||||
name?: string
|
||||
icon?: string
|
||||
label?: string
|
||||
tooltip?: string
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ name, icon, label, tooltip }) => {
|
||||
window.app!.registerExtension({
|
||||
name,
|
||||
actionBarButtons: [{ icon, label, tooltip, onClick: () => {} }]
|
||||
})
|
||||
},
|
||||
{
|
||||
name: opts.name ?? 'TestActionBarButton',
|
||||
icon: opts.icon ?? ICON_CLASS,
|
||||
label: opts.label ?? BUTTON_LABEL,
|
||||
tooltip: opts.tooltip ?? BUTTON_TOOLTIP
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('ActionBar Buttons', { tag: ['@ui'] }, () => {
|
||||
test.describe('Empty state', () => {
|
||||
test('container is hidden when no extension registers buttons', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.actionBarButtons)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Button rendering', () => {
|
||||
test('registered button is visible with correct label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await registerTestButton(comfyPage.page)
|
||||
const container = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.actionBarButtons
|
||||
)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(
|
||||
container.getByRole('button', { name: BUTTON_TOOLTIP })
|
||||
).toBeVisible()
|
||||
await expect(container.getByText(BUTTON_LABEL)).toBeVisible()
|
||||
})
|
||||
|
||||
test('button icon is rendered', async ({ comfyPage }) => {
|
||||
await registerTestButton(comfyPage.page)
|
||||
const icon = comfyPage.page
|
||||
.getByTestId(TestIds.topbar.actionBarButtons)
|
||||
.getByRole('button', { name: BUTTON_TOOLTIP })
|
||||
.locator('i')
|
||||
await expect(icon).toHaveClass(ICON_CLASS)
|
||||
})
|
||||
|
||||
test('multiple registered buttons all appear', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestActionBarButtons',
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: 'icon-[lucide--star]',
|
||||
label: 'First',
|
||||
tooltip: 'First action',
|
||||
onClick: () => {}
|
||||
},
|
||||
{
|
||||
icon: 'icon-[lucide--heart]',
|
||||
label: 'Second',
|
||||
tooltip: 'Second action',
|
||||
onClick: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const container = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.actionBarButtons
|
||||
)
|
||||
await expect(
|
||||
container.getByRole('button', { name: 'First action' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
container.getByRole('button', { name: 'Second action' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Click handler', () => {
|
||||
test('clicking a button fires its onClick handler', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const onClickFired = comfyPage.page.evaluate(
|
||||
({ icon, label, tooltip }) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestActionBarButton',
|
||||
actionBarButtons: [
|
||||
{ icon, label, tooltip, onClick: () => resolve(true) }
|
||||
]
|
||||
})
|
||||
}),
|
||||
{ icon: ICON_CLASS, label: BUTTON_LABEL, tooltip: BUTTON_TOOLTIP }
|
||||
)
|
||||
|
||||
const button = comfyPage.page
|
||||
.getByTestId(TestIds.topbar.actionBarButtons)
|
||||
.getByRole('button', { name: BUTTON_TOOLTIP })
|
||||
await button.click()
|
||||
|
||||
await expect(onClickFired).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mobile layout', { tag: ['@mobile'] }, () => {
|
||||
test('button label is hidden on mobile viewport', async ({ comfyPage }) => {
|
||||
await registerTestButton(comfyPage.page)
|
||||
const container = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.actionBarButtons
|
||||
)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(container.getByText(BUTTON_LABEL)).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
test.describe('App mode arrange step', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
|
||||
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
|
||||
const RESIZE_NODE_ID = '1'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import {
|
||||
getClipboardText,
|
||||
interceptClipboardWrite
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
} from '@e2e/fixtures/utils/clipboardSpy'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '@e2e/helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
} from '@e2e/fixtures/utils/builderTestUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
|
||||
175
browser_tests/tests/canvasLayoutSettings.spec.ts
Normal file
175
browser_tests/tests/canvasLayoutSettings.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Size } from '@e2e/fixtures/types'
|
||||
|
||||
const expectedGroupSize = (
|
||||
nodeBounds: Size,
|
||||
padding: number,
|
||||
titleHeight: number
|
||||
): Size => ({
|
||||
width: nodeBounds.width + padding * 2,
|
||||
// Group height adds one title row above the contained node bounds (which
|
||||
// themselves already include the node's own title), independent of padding.
|
||||
height: nodeBounds.height + padding * 2 + titleHeight
|
||||
})
|
||||
|
||||
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.SnapToGrid.GridSize', () => {
|
||||
const DRAG_DELTA = { x: 550, y: 330 } as const
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
const createNode = async (comfyPage: ComfyPage) => {
|
||||
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
await note.centerOnNode()
|
||||
return note
|
||||
}
|
||||
|
||||
test('shift+drag rounds final node position to multiples of grid size', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
|
||||
const note = await createNode(comfyPage)
|
||||
|
||||
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
|
||||
|
||||
// raw final world pos = (550, 330); rounded to nearest 100 = (600, 300)
|
||||
const after = await note.getProperty<[number, number]>('pos')
|
||||
expect(after[0]).toBe(600)
|
||||
expect(after[1]).toBe(300)
|
||||
})
|
||||
|
||||
test('grid size determines the snap multiple', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 50)
|
||||
const note = await createNode(comfyPage)
|
||||
|
||||
await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] })
|
||||
|
||||
// raw final world pos = (550, 330); rounded to nearest 50 = (550, 350)
|
||||
const after = await note.getProperty<[number, number]>('pos')
|
||||
expect(after[0]).toBe(550)
|
||||
expect(after[1]).toBe(350)
|
||||
})
|
||||
|
||||
test('drag without shift bypasses snap regardless of grid size', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100)
|
||||
const note = await createNode(comfyPage)
|
||||
const before = await note.getProperty<[number, number]>('pos')
|
||||
|
||||
await note.dragBy(DRAG_DELTA)
|
||||
|
||||
const after = await note.getProperty<[number, number]>('pos')
|
||||
expect(after[0]).toBe(before[0] + DRAG_DELTA.x)
|
||||
expect(after[1]).toBe(before[1] + DRAG_DELTA.y)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.GroupSelectedNodes.Padding', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
const groupAroundAllNodesWithPadding = async (
|
||||
comfyPage: ComfyPage,
|
||||
padding: number
|
||||
): Promise<Size> => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.GroupSelectedNodes.Padding',
|
||||
padding
|
||||
)
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.SelectAll')
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.GroupSelectedNodes')
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const group = window.app!.graph.groups[0]
|
||||
return { width: group.size[0], height: group.size[1] }
|
||||
})
|
||||
}
|
||||
|
||||
test('padding=0 makes the group exactly enclose the selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksampler = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
)[0]
|
||||
const nodeBounds = await ksampler.getBounding()
|
||||
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
|
||||
|
||||
const group = await groupAroundAllNodesWithPadding(comfyPage, 0)
|
||||
|
||||
expect(group).toEqual(expectedGroupSize(nodeBounds, 0, titleHeight))
|
||||
})
|
||||
|
||||
test('padding=50 grows the group by 100 around the selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const ksampler = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
)[0]
|
||||
const nodeBounds = await ksampler.getBounding()
|
||||
const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight()
|
||||
|
||||
const group = await groupAroundAllNodesWithPadding(comfyPage, 50)
|
||||
|
||||
expect(group).toEqual(expectedGroupSize(nodeBounds, 50, titleHeight))
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('LiteGraph.ContextMenu.Scaling', () => {
|
||||
const ZOOM_SCALE = 2
|
||||
const litegraphContextMenu = (comfyPage: ComfyPage) =>
|
||||
comfyPage.page.locator('.litecontextmenu')
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
|
||||
})
|
||||
|
||||
const openComboMenu = async (comfyPage: ComfyPage) => {
|
||||
const loadImage = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const fileCombo = await loadImage.getWidget(0)
|
||||
await fileCombo.click()
|
||||
}
|
||||
|
||||
test('combo widget popup is scaled when setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('LiteGraph.ContextMenu.Scaling', true)
|
||||
|
||||
await openComboMenu(comfyPage)
|
||||
|
||||
const menu = litegraphContextMenu(comfyPage)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu).toHaveCSS(
|
||||
'transform',
|
||||
`matrix(${ZOOM_SCALE}, 0, 0, ${ZOOM_SCALE}, 0, 0)`
|
||||
)
|
||||
})
|
||||
|
||||
test('combo widget popup is not scaled when setting is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.ContextMenu.Scaling',
|
||||
false
|
||||
)
|
||||
|
||||
await openComboMenu(comfyPage)
|
||||
|
||||
const menu = litegraphContextMenu(comfyPage)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu).toHaveCSS('transform', 'none')
|
||||
})
|
||||
})
|
||||
})
|
||||
400
browser_tests/tests/canvasSettings.spec.ts
Normal file
400
browser_tests/tests/canvasSettings.spec.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
const CLIP_NODE_COUNT = 2
|
||||
|
||||
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
|
||||
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
clipNodes,
|
||||
'Default workflow is expected to contain exactly two CLIPTextEncode nodes'
|
||||
).toHaveLength(CLIP_NODE_COUNT)
|
||||
const p1 = await clipNodes[0].getPosition()
|
||||
const p2 = await clipNodes[1].getPosition()
|
||||
const margin = 64
|
||||
const from = await comfyPage.canvasOps.toAbsolute({
|
||||
x: Math.min(p1.x, p2.x) - margin,
|
||||
y: Math.min(p1.y, p2.y) - margin
|
||||
})
|
||||
const to = await comfyPage.canvasOps.toAbsolute({
|
||||
x: Math.max(p1.x, p2.x) + margin,
|
||||
y: Math.max(p1.y, p2.y) + margin
|
||||
})
|
||||
return { from, to }
|
||||
}
|
||||
|
||||
test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Graph.CanvasInfo', () => {
|
||||
test(
|
||||
'toggles the bottom-left HUD',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const box = await comfyPage.canvas.boundingBox()
|
||||
expect(box, 'Canvas bounding box must be available').not.toBeNull()
|
||||
// HUD is drawn ~80px tall along the bottom edge of the canvas; grab a
|
||||
// comfortable 180px × 160px strip to catch it across viewports.
|
||||
const HUD_WIDTH = 180
|
||||
const HUD_HEIGHT = 160
|
||||
const hudClip = {
|
||||
x: box!.x,
|
||||
y: box!.y + box!.height - HUD_HEIGHT,
|
||||
width: HUD_WIDTH,
|
||||
height: HUD_HEIGHT
|
||||
}
|
||||
|
||||
await test.step('Capture HUD region with setting off', async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-off.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Capture HUD region with setting on', async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Comfy.Graph.CtrlShiftZoom', () => {
|
||||
const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 }
|
||||
const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 }
|
||||
|
||||
test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true)
|
||||
await comfyPage.canvasOps.resetView()
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
CTRL_SHIFT_DRAG_FROM,
|
||||
CTRL_SHIFT_DRAG_TO
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale())
|
||||
.not.toBeCloseTo(initialScale, 2)
|
||||
})
|
||||
|
||||
test('Ctrl+Shift+drag does not zoom when disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false)
|
||||
await comfyPage.canvasOps.resetView()
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
CTRL_SHIFT_DRAG_FROM,
|
||||
CTRL_SHIFT_DRAG_TO
|
||||
)
|
||||
|
||||
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Graph.LiveSelection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'standard'
|
||||
)
|
||||
})
|
||||
|
||||
test('selects nodes mid-drag when enabled', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true)
|
||||
const { from, to } = await getClipNodesDragBox(comfyPage)
|
||||
|
||||
await comfyPage.page.mouse.move(from.x, from.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(CLIP_NODE_COUNT)
|
||||
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('defers selection to drag end when disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false)
|
||||
const { from, to } = await getClipNodesDragBox(comfyPage)
|
||||
|
||||
await comfyPage.page.mouse.move(from.x, from.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 })
|
||||
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
|
||||
|
||||
await comfyPage.page.mouse.up()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(CLIP_NODE_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Canvas.MouseWheelScroll', () => {
|
||||
const WHEEL_POS = { x: 400, y: 400 }
|
||||
|
||||
test('wheel zooms when set to zoom', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.MouseWheelScroll',
|
||||
'zoom'
|
||||
)
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo(
|
||||
initialScale,
|
||||
3
|
||||
)
|
||||
})
|
||||
|
||||
test('wheel pans when set to panning', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.MouseWheelScroll',
|
||||
'panning'
|
||||
)
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
const initialOffset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y)
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3)
|
||||
const offset = await comfyPage.canvasOps.getOffset()
|
||||
expect(
|
||||
Math.abs(offset[0] - initialOffset[0]) +
|
||||
Math.abs(offset[1] - initialOffset[1])
|
||||
).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => {
|
||||
test('override to panning makes empty left-drag pan the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior',
|
||||
'select'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior',
|
||||
'panning'
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.canvasOps.resetView()
|
||||
|
||||
const initialOffset = await comfyPage.canvasOps.getOffset()
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: 200, y: 300 },
|
||||
{ x: 400, y: 500 }
|
||||
)
|
||||
const offset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
expect(
|
||||
Math.abs(offset[0] - initialOffset[0]) +
|
||||
Math.abs(offset[1] - initialOffset[1])
|
||||
).toBeGreaterThan(50)
|
||||
expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('override to select turns empty left-drag into a selection rectangle', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior',
|
||||
'select'
|
||||
)
|
||||
const { from, to } = await getClipNodesDragBox(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.dragAndDrop(from, to)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(CLIP_NODE_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Pointer settings', () => {
|
||||
/**
|
||||
* Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no
|
||||
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
|
||||
* because it exists only to probe the CanvasPointer timing thresholds.
|
||||
*/
|
||||
const holdDragAt = async (
|
||||
comfyPage: ComfyPage,
|
||||
pos: { x: number; y: number },
|
||||
opts: { dx: number; dy: number; holdMs: number }
|
||||
) => {
|
||||
const abs = await comfyPage.canvasOps.toAbsolute(pos)
|
||||
await comfyPage.page.mouse.move(abs.x, abs.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await sleep(opts.holdMs)
|
||||
await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('DoubleClickTime controls whether two clicks open the title editor', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.DoubleClickTitleToEdit',
|
||||
true
|
||||
)
|
||||
const clipNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
clipNodes,
|
||||
'Default workflow must have CLIPTextEncode nodes'
|
||||
).toHaveLength(CLIP_NODE_COUNT)
|
||||
const titlePos = await clipNodes[0].getTitlePosition()
|
||||
const CLICK_GAP_MS = 200
|
||||
|
||||
await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Pointer.DoubleClickTime',
|
||||
100
|
||||
)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await sleep(CLICK_GAP_MS)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await comfyPage.titleEditor.expectHidden()
|
||||
})
|
||||
|
||||
await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Pointer.DoubleClickTime',
|
||||
1000
|
||||
)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await sleep(CLICK_GAP_MS)
|
||||
await comfyPage.canvasOps.mouseClickAt(titlePos)
|
||||
await comfyPage.titleEditor.expectVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('ClickBufferTime governs the click-vs-drag time threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Keep drift generous so only elapsed time distinguishes click vs drag.
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
|
||||
const node = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
)[0]
|
||||
const titlePos = await node.getTitlePosition()
|
||||
const NUDGE = 2
|
||||
const HOLD_MS = 250
|
||||
|
||||
await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Pointer.ClickBufferTime',
|
||||
2000
|
||||
)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: HOLD_MS
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(after.x).toBeCloseTo(before.x, 0)
|
||||
expect(after.y).toBeCloseTo(before.y, 0)
|
||||
})
|
||||
|
||||
await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: HOLD_MS
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(
|
||||
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('ClickDrift governs the click-vs-drag distance threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Keep buffer generous so only drift distance matters.
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000)
|
||||
const node = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
)[0]
|
||||
const titlePos = await node.getTitlePosition()
|
||||
const NUDGE = 8
|
||||
|
||||
await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: 0
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(after.x).toBeCloseTo(before.x, 0)
|
||||
expect(after.y).toBeCloseTo(before.y, 0)
|
||||
})
|
||||
|
||||
await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1)
|
||||
const before = await node.getPosition()
|
||||
await holdDragAt(comfyPage, titlePos, {
|
||||
dx: NUDGE,
|
||||
dy: NUDGE,
|
||||
holdMs: 0
|
||||
})
|
||||
const after = await node.getPosition()
|
||||
expect(
|
||||
Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('LiteGraph.Canvas.MaximumFps', () => {
|
||||
// Behavioural FPS counting via rAF is not reliable under Playwright
|
||||
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
|
||||
// render-loop throttle value instead — that is what actually governs
|
||||
// frame cadence.
|
||||
const getFrameGap = (comfyPage: ComfyPage) =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
|
||||
|
||||
test('caps the render loop frame gap', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
|
||||
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1)
|
||||
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60)
|
||||
await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1)
|
||||
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0)
|
||||
await expect.poll(() => getFrameGap(comfyPage)).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -12,25 +13,35 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
|
||||
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
const test = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const pattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
)
|
||||
}
|
||||
await page.route(pattern, assetsRouteHandler)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
await page.unroute(pattern, assetsRouteHandler)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
@@ -42,23 +53,36 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
// Wait for the checkpoint asset query to complete and the existing widget
|
||||
// to upgrade into asset mode before creating a fresh node. The current
|
||||
// default node may keep a previously resolved value; what matters is that
|
||||
// new nodes resolve against the cloud asset list after the fetch.
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
comfyPage.page.evaluate((waitingForWidgetType) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
return (
|
||||
node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type ?? waitingForWidgetType
|
||||
)
|
||||
}, WAITING_FOR_WIDGET_TYPE),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
@@ -81,15 +105,22 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return 'waiting:type'
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? 'waiting:value' : val
|
||||
}, nodeId),
|
||||
comfyPage.page.evaluate(
|
||||
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
const widget = node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
if (widget?.type !== 'asset') return waitingForWidgetType
|
||||
const val = String(widget?.value ?? '')
|
||||
return val === 'Select model' ? waitingForWidgetValue : val
|
||||
},
|
||||
{
|
||||
id: nodeId,
|
||||
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
|
||||
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
|
||||
}
|
||||
),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(CLOUD_ASSETS[0].name)
|
||||
|
||||
306
browser_tests/tests/dialogs/publishDialog.spec.ts
Normal file
306
browser_tests/tests/dialogs/publishDialog.spec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
|
||||
|
||||
const PUBLISH_FEATURE_FLAGS = {
|
||||
comfyhub_upload_enabled: true,
|
||||
comfyhub_profile_gate_enabled: true
|
||||
} as const
|
||||
|
||||
async function saveAndOpenPublishDialog(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: PublishDialog,
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
} catch {
|
||||
// No overwrite dialog — workflow name was unique.
|
||||
}
|
||||
|
||||
await dialog.open()
|
||||
}
|
||||
|
||||
test.describe('Publish dialog - wizard navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
|
||||
})
|
||||
|
||||
test('opens on the Describe step by default', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
await expect(publishDialog.nameInput).toBeVisible()
|
||||
await expect(publishDialog.descriptionTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test('pre-fills workflow name from active workflow', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
|
||||
})
|
||||
|
||||
test('Next button navigates to Examples step', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
// Examples step should show thumbnail toggle and upload area
|
||||
await expect(
|
||||
publishDialog.root.getByText('Select a thumbnail')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back button returns to Describe step from Examples', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.goNext()
|
||||
await expect(publishDialog.describeStep).toBeHidden()
|
||||
|
||||
await publishDialog.goBack()
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigates through all steps to Finish', async ({ publishDialog }) => {
|
||||
await publishDialog.goNext() // → Examples
|
||||
await publishDialog.goNext() // → Finish
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking nav step navigates directly', async ({ publishDialog }) => {
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.goToStep('Describe your workflow')
|
||||
await expect(publishDialog.describeStep).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(publishDialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Describe step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
|
||||
})
|
||||
|
||||
test('allows editing the workflow name', async ({ publishDialog }) => {
|
||||
await publishDialog.nameInput.clear()
|
||||
await publishDialog.nameInput.fill('My Custom Workflow')
|
||||
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
|
||||
})
|
||||
|
||||
test('allows editing the description', async ({ publishDialog }) => {
|
||||
await publishDialog.descriptionTextarea.fill(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
await expect(publishDialog.descriptionTextarea).toHaveValue(
|
||||
'A great workflow for anime art'
|
||||
)
|
||||
})
|
||||
|
||||
test('displays tag suggestions from mocked API', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.root.getByText('anime')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
|
||||
// the active list during E2E. Needs investigation of the parent state
|
||||
// binding.
|
||||
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
|
||||
await publishDialog.root.getByText('anime').click()
|
||||
|
||||
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Examples step', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
|
||||
await publishDialog.goNext() // Navigate to Examples step
|
||||
})
|
||||
|
||||
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Video', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Image comparison', { exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows example image upload tile', async ({ publishDialog }) => {
|
||||
await expect(
|
||||
publishDialog.root.getByRole('button', { name: 'Upload example image' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile card with username', async ({ publishDialog }) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
|
||||
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
|
||||
})
|
||||
|
||||
test('publish button is enabled when no private assets', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - Finish step with private assets', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({
|
||||
hasProfile: true,
|
||||
hasPrivateAssets: true
|
||||
})
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('publish button is disabled until assets acknowledged', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
await expect(publishDialog.publishButton).toBeDisabled()
|
||||
|
||||
const checkbox = publishDialog.finishStep.getByRole('checkbox')
|
||||
await checkbox.check()
|
||||
|
||||
await expect(publishDialog.publishButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - no profile', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: false })
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-noprofile-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
})
|
||||
|
||||
test('shows profile creation prompt when user has no profile', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await expect(publishDialog.profilePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking create profile CTA shows profile creation form', async ({
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishDialog.root
|
||||
.getByRole('button', { name: 'Create a profile' })
|
||||
.click()
|
||||
await expect(publishDialog.gateFlow).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - unsaved workflow', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks()
|
||||
// Don't save workflow — open dialog on the default temporary workflow
|
||||
})
|
||||
|
||||
test('shows save prompt for temporary workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
// Create a new workflow to ensure it's temporary
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await publishDialog.open()
|
||||
|
||||
await expect(publishDialog.savePrompt).toBeVisible()
|
||||
await expect(
|
||||
publishDialog.root.getByText(
|
||||
'You must save your workflow before publishing'
|
||||
)
|
||||
).toBeVisible()
|
||||
// Nav should be hidden when save is required
|
||||
await expect(publishDialog.nav).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Publish dialog - submission', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
})
|
||||
|
||||
test('successful publish closes dialog', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('failed publish shows error toast', async ({
|
||||
comfyPage,
|
||||
publishApi,
|
||||
publishDialog
|
||||
}) => {
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
// Override publish mock with error response
|
||||
await publishApi.mockPublishWorkflowError(500, 'Internal error')
|
||||
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-submit-fail-wf'
|
||||
)
|
||||
await publishDialog.goToStep('Finish publishing')
|
||||
await expect(publishDialog.finishStep).toBeVisible()
|
||||
|
||||
await publishDialog.publishButton.click()
|
||||
|
||||
// Error toast should appear
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
// Dialog should remain open
|
||||
await expect(publishDialog.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
} from '@e2e/fixtures/utils/clipboardSpy'
|
||||
|
||||
async function triggerConfigureError(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
208
browser_tests/tests/linkNodeInteractionSettings.spec.ts
Normal file
208
browser_tests/tests/linkNodeInteractionSettings.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
|
||||
const VAE_DECODE_SAMPLES_INPUT_SLOT = 0
|
||||
const DEFAULT_GROUP_TITLE = 'Group'
|
||||
|
||||
test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.LinkRelease.Action', () => {
|
||||
test('"search box" opens node search on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(comfyPage.searchBoxV2.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('"context menu" opens litegraph connection menu on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'context menu'
|
||||
)
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('"no action" suppresses both search box and context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'no action'
|
||||
)
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(comfyPage.searchBoxV2.input).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.LinkRelease.ActionShift', () => {
|
||||
test('shift+drag dispatches to ActionShift (not Action)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'no action'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
|
||||
await comfyPage.canvasOps.disconnectEdge({ modifiers: ['Shift'] })
|
||||
|
||||
await expect(comfyPage.searchBoxV2.input).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Node.DoubleClickTitleToEdit', () => {
|
||||
test('enabled → double-click on node title opens editor', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.DoubleClickTitleToEdit',
|
||||
true
|
||||
)
|
||||
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
|
||||
await comfyPage.titleEditor.expectVisible()
|
||||
})
|
||||
|
||||
test('disabled → double-click on node title stays hidden', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.DoubleClickTitleToEdit',
|
||||
false
|
||||
)
|
||||
const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition())
|
||||
await comfyPage.titleEditor.expectHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Group.DoubleClickTitleToEdit', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
|
||||
})
|
||||
|
||||
test('enabled → double-click on group title opens editor', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Group.DoubleClickTitleToEdit',
|
||||
true
|
||||
)
|
||||
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
|
||||
await comfyPage.titleEditor.expectVisible()
|
||||
})
|
||||
|
||||
test('disabled → double-click on group title stays hidden', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Group.DoubleClickTitleToEdit',
|
||||
false
|
||||
)
|
||||
await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE)
|
||||
await comfyPage.titleEditor.expectHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Node.BypassAllLinksOnDelete', () => {
|
||||
test('enabled → deleting KSampler bridges EmptyLatentImage → VAEDecode.samples', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.BypassAllLinksOnDelete',
|
||||
true
|
||||
)
|
||||
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
const [emptyLatent] =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('EmptyLatentImage')
|
||||
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
|
||||
const vaeSamplesInput = await vaeDecode.getInput(
|
||||
VAE_DECODE_SAMPLES_INPUT_SLOT
|
||||
)
|
||||
|
||||
await test.step('precondition: KSampler feeds VAEDecode.samples', async () => {
|
||||
expect(
|
||||
(await vaeSamplesInput.getLink())?.origin_id,
|
||||
'VAEDecode.samples should originate from KSampler before delete'
|
||||
).toBe(kSampler.id)
|
||||
})
|
||||
|
||||
await kSampler.delete()
|
||||
|
||||
await expect
|
||||
.poll(async () => (await vaeSamplesInput.getLink())?.origin_id ?? null)
|
||||
.toBe(emptyLatent.id)
|
||||
})
|
||||
|
||||
test('disabled → deleting KSampler drops VAEDecode.samples', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.BypassAllLinksOnDelete',
|
||||
false
|
||||
)
|
||||
const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode')
|
||||
const vaeSamplesInput = await vaeDecode.getInput(
|
||||
VAE_DECODE_SAMPLES_INPUT_SLOT
|
||||
)
|
||||
|
||||
await kSampler.delete()
|
||||
|
||||
await expect.poll(() => vaeSamplesInput.getLink()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Node.MiddleClickRerouteNode', () => {
|
||||
async function countReroutes(comfyPage: ComfyPage): Promise<number> {
|
||||
return (await comfyPage.nodeOps.getNodeRefsByType('Reroute')).length
|
||||
}
|
||||
|
||||
test('enabled → middle-click on an output slot creates a Reroute', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.MiddleClickRerouteNode',
|
||||
true
|
||||
)
|
||||
const before = await countReroutes(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.middleClick(
|
||||
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
|
||||
)
|
||||
|
||||
await expect.poll(() => countReroutes(comfyPage)).toBe(before + 1)
|
||||
})
|
||||
|
||||
test('disabled → middle-click on an output slot does nothing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.MiddleClickRerouteNode',
|
||||
false
|
||||
)
|
||||
const before = await countReroutes(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.middleClick(
|
||||
DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await countReroutes(comfyPage)).toBe(before)
|
||||
})
|
||||
})
|
||||
})
|
||||
106
browser_tests/tests/loginButton.spec.ts
Normal file
106
browser_tests/tests/loginButton.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SignInDialog } from '@e2e/fixtures/components/SignInDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Enable the show_signin_button server feature flag so LoginButton renders
|
||||
* in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`).
|
||||
* The flag is reset automatically on each fresh page load in beforeEach.
|
||||
*/
|
||||
async function enableLoginButtonFlag(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
show_signin_button: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Login Button', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.describe('Visibility', () => {
|
||||
test('button is hidden when show_signin_button flag is off', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
show_signin_button: false
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('button is visible when show_signin_button flag is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ARIA', () => {
|
||||
test('button has correct aria-label', async ({ comfyPage }) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
await expect(button).toHaveAttribute('aria-label', /.+/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Click behaviour', () => {
|
||||
test('clicking the button opens the sign-in dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
const dialog = new SignInDialog(comfyPage.page)
|
||||
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click()
|
||||
await expect(dialog.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Hover popover', () => {
|
||||
test('hovering shows an informational popover', async ({ comfyPage }) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('popover contains a Learn more link', async ({ comfyPage }) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover()
|
||||
const learnMoreLink = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.loginButtonPopoverLearnMore
|
||||
)
|
||||
await expect(learnMoreLink).toBeVisible()
|
||||
await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/)
|
||||
})
|
||||
|
||||
test('popover hides after mouse leaves the button area', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await enableLoginButtonFlag(comfyPage.page)
|
||||
const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton)
|
||||
await button.hover()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.canvas.hover()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,18 +22,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
const nodeRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId, centerX, centerY }
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
@@ -80,7 +76,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
@@ -153,5 +148,124 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('moving ghost onto existing node and clicking places correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get existing KSampler node from the default workflow
|
||||
const [ksamplerRef] =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
const ksamplerPos = await ksamplerRef.getPosition()
|
||||
const ksamplerSize = await ksamplerRef.getSize()
|
||||
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
|
||||
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
|
||||
|
||||
// Start ghost placement away from the existing node
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const ghostRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: startX, y: startY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Move ghost onto the existing node
|
||||
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click to finalize — on top of the existing node
|
||||
await comfyPage.page.mouse.click(targetX, targetY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Ghost should be placed (no longer ghost)
|
||||
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
|
||||
expect(ghostResult).not.toBeNull()
|
||||
expect(ghostResult!.ghost).toBe(false)
|
||||
|
||||
// Ghost node should have moved from its start position toward where we clicked
|
||||
const ghostPos = await ghostRef.getPosition()
|
||||
expect(
|
||||
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
|
||||
).toBe(true)
|
||||
|
||||
// Existing node should NOT be selected
|
||||
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
|
||||
expect(selectedIds).not.toContain(ksamplerRef.id)
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph blueprint added from search box enters ghost mode',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
|
||||
// Convert a node to a subgraph and publish it as a blueprint
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
expect(subgraphNodes).toHaveLength(1)
|
||||
const subgraphNode = subgraphNodes[0]
|
||||
|
||||
const blueprintName = `ghost-test-${Date.now()}`
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name: blueprintName
|
||||
})
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
|
||||
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Open v2 search box and search for the published blueprint
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill(blueprintName)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Click the result to add the node (v2 search box uses ghost mode)
|
||||
await searchBoxV2.results.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// A new node should exist on the graph in ghost mode
|
||||
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
|
||||
|
||||
const ghostNodeId = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.state.ghostNodeId
|
||||
})
|
||||
expect(ghostNodeId).not.toBeNull()
|
||||
|
||||
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(ghostState).not.toBeNull()
|
||||
expect(ghostState!.ghost).toBe(true)
|
||||
|
||||
// Wait for search box to close, then click to confirm placement
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await comfyPage.nextFrame()
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page.mouse.click(
|
||||
Math.round(viewport.width / 2),
|
||||
Math.round(viewport.height / 2)
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
|
||||
expect(afterPlace).not.toBeNull()
|
||||
expect(afterPlace!.ghost).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
|
||||
@@ -5,32 +5,19 @@ import {
|
||||
|
||||
test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
})
|
||||
|
||||
test('Can open search and add node', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
@@ -40,33 +27,28 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Default results should be visible without typing
|
||||
await searchBoxV2.open()
|
||||
// Default results should be visible without typing.
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Enter should add the first (selected) result
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
|
||||
test('Bookmarked filter shows only bookmarked nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
])
|
||||
await searchBoxV2.reload(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('favorites').click()
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.rootCategoryButton('favorites').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
@@ -77,13 +59,10 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,26 +70,23 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.open()
|
||||
|
||||
// Click "Input" filter chip in the filter bar
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await test.step('Open Input filter popover', async () => {
|
||||
await searchBoxV2.typeFilterButton('input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
})
|
||||
|
||||
// Filter options should appear
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await test.step('Select MODEL type', async () => {
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
|
||||
// Type to narrow and select MODEL
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Filter chip should appear and results should be filtered
|
||||
await expect(
|
||||
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
|
||||
).toContainText('MODEL')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -120,32 +96,33 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
|
||||
// First result selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
await test.step('First result is selected by default', async () => {
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
// ArrowDown moves selection
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
|
||||
await test.step('ArrowDown moves selection to next result', async () => {
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
// ArrowUp moves back
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
await test.step('ArrowUp moves selection back', async () => {
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
// Enter selects and adds node
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
await test.step('Enter selects and adds the node', async () => {
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,27 +2,17 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
await comfyPage.searchBoxV2.setup()
|
||||
})
|
||||
|
||||
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.openByDoubleClickCanvas()
|
||||
await expect(searchBoxV2.dialog).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -32,43 +22,40 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount)
|
||||
})
|
||||
|
||||
test('Search clears when reopening', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
for (const closeKey of ['Enter', 'Escape'] as const) {
|
||||
test(`Reopening search after ${closeKey} has no persisted state`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press(closeKey)
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await expect(searchBoxV2.input).toHaveValue('')
|
||||
})
|
||||
await searchBoxV2.open()
|
||||
await expect(searchBoxV2.input).toHaveValue('')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Category navigation updates results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
@@ -76,7 +63,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
|
||||
await searchBoxV2.categoryButton('loaders').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.allTextContents())
|
||||
.not.toEqual(samplingResults)
|
||||
@@ -87,58 +73,328 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test('Filter chip removal restores results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.open()
|
||||
|
||||
// Record initial result text for comparison
|
||||
// Search first to keep the result set under the 64-item cap.
|
||||
await searchBoxV2.input.fill('Load')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredResults = await searchBoxV2.results.allTextContents()
|
||||
const unfilteredCount = await searchBoxV2.results.count()
|
||||
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
await test.step('Apply Input/MODEL filter', async () => {
|
||||
await searchBoxV2.applyTypeFilter('input', 'MODEL')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.not.toBe(unfilteredCount)
|
||||
})
|
||||
|
||||
// Verify filter chip appeared and results changed
|
||||
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
|
||||
await expect(filterChip).toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.allTextContents())
|
||||
.not.toEqual(unfilteredResults)
|
||||
|
||||
// Remove filter by clicking the chip delete button
|
||||
await filterChip.getByTestId('chip-delete').click()
|
||||
|
||||
// Filter chip should be removed
|
||||
await expect(filterChip).toBeHidden()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await test.step('Remove the filter chip', async () => {
|
||||
await searchBoxV2.removeFilterChip()
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(0)
|
||||
await expect(searchBoxV2.results).toHaveCount(unfilteredCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowUp on first item keeps first selected', async ({
|
||||
test.describe('Link release', () => {
|
||||
test('Link release opens search with pre-applied type filter', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// disconnectEdge pulls a CLIP link → expect a single CLIP filter chip.
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
|
||||
})
|
||||
|
||||
test('Link release auto-connects added node', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const NODE_TYPE = 'CLIPTextEncode'
|
||||
const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
|
||||
const idsBefore = new Set(refsBefore.map((n) => n.id))
|
||||
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('CLIP Text Encode')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
// A new CLIPTextEncode node should have been added.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.nodeOps
|
||||
.getNodeRefsByType(NODE_TYPE)
|
||||
.then((refs) => refs.length)
|
||||
)
|
||||
.toBe(refsBefore.length + 1)
|
||||
|
||||
// Verify the auto-connect: the newly-added node's CLIP input must be
|
||||
// connected (proves the release wasn't just dropped).
|
||||
const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE)
|
||||
const newNode = refsAfter.find((n) => !idsBefore.has(n.id))
|
||||
expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined()
|
||||
const clipInput = await newNode!.getInput(0)
|
||||
await expect.poll(() => clipInput.getLinkCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter combinations', () => {
|
||||
test('Output type filter filters results', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill('Load')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.applyTypeFilter('output', 'IMAGE')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.not.toBe(unfilteredCount)
|
||||
})
|
||||
|
||||
test('Multiple type filters (Input + Output) narrows results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.applyTypeFilter('input', 'MODEL')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const singleFilterCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.applyTypeFilter('output', 'LATENT')
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(2)
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(singleFilterCount)
|
||||
})
|
||||
|
||||
test('Root filter + search query narrows results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('Sampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const unfilteredCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.rootCategoryButton('comfy').click()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(unfilteredCount)
|
||||
await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Root filter + category selection', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.rootCategoryButton('comfy').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const comfyCount = await searchBoxV2.results.count()
|
||||
|
||||
// Under root filter, categories are prefixed (e.g. comfy/sampling).
|
||||
await searchBoxV2.categoryButton('comfy/sampling').click()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(comfyCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Category sidebar', () => {
|
||||
test('Category tree expand and collapse', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const samplingBtn = searchBoxV2.categoryButton('sampling')
|
||||
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
|
||||
|
||||
await test.step('Expanding sampling reveals its subcategories', async () => {
|
||||
await samplingBtn.click()
|
||||
await expect(subcategory).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Collapsing sampling hides its subcategories', async () => {
|
||||
await samplingBtn.click()
|
||||
await expect(subcategory).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const parentCount = await searchBoxV2.results.count()
|
||||
|
||||
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
|
||||
await expect(subcategory).toBeVisible()
|
||||
await subcategory.click()
|
||||
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.toBeLessThan(parentCount)
|
||||
})
|
||||
|
||||
test('Most relevant resets category filter', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const defaultCount = await searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
await expect
|
||||
.poll(() => searchBoxV2.results.count())
|
||||
.not.toBe(defaultCount)
|
||||
|
||||
await searchBoxV2.categoryButton('most-relevant').click()
|
||||
await expect(searchBoxV2.results).toHaveCount(defaultCount)
|
||||
})
|
||||
|
||||
test(
|
||||
'Blueprint root chip filters to published blueprints',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}`
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.nodeOps
|
||||
.getNodeRefsByTitle('New Subgraph')
|
||||
.then((refs) => refs.length)
|
||||
)
|
||||
.toBe(1)
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
await subgraphNodes[0].click('title')
|
||||
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
|
||||
name: blueprintName
|
||||
})
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
const blueprintsChip = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Blueprint
|
||||
)
|
||||
await expect(blueprintsChip).toBeVisible()
|
||||
await blueprintsChip.click()
|
||||
|
||||
// Blueprints persist across tests on the same worker; filter by the
|
||||
// unique name we just published rather than asserting the full list.
|
||||
await expect(
|
||||
searchBoxV2.results.filter({ hasText: blueprintName })
|
||||
).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const getCount = () => searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill('S')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const count1 = await getCount()
|
||||
|
||||
await searchBoxV2.input.fill('Sa')
|
||||
await expect.poll(getCount).toBeLessThan(count1)
|
||||
const count2 = await getCount()
|
||||
|
||||
await searchBoxV2.input.fill('Sampler')
|
||||
await expect.poll(getCount).toBeLessThan(count2)
|
||||
})
|
||||
|
||||
test('No results shown for nonsensical query', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
|
||||
|
||||
await expect(searchBoxV2.noResults).toBeVisible()
|
||||
await expect(searchBoxV2.results).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter chip interaction', () => {
|
||||
test('Multiple filter chips displayed', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.applyTypeFilter('input', 'MODEL')
|
||||
await searchBoxV2.applyTypeFilter('output', 'LATENT')
|
||||
|
||||
await expect(searchBoxV2.filterChips).toHaveCount(2)
|
||||
const chipTexts = await searchBoxV2.filterChips.allTextContents()
|
||||
expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true)
|
||||
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings-driven behavior', () => {
|
||||
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.ShowIdName',
|
||||
true
|
||||
)
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.input.fill('VAE Decode')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible()
|
||||
await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode')
|
||||
})
|
||||
|
||||
test('Follow-cursor disabled places node without ghost mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor',
|
||||
false
|
||||
)
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// First result should be selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
await searchBoxV2.results.first().click()
|
||||
await expect(searchBoxV2.input).toBeHidden()
|
||||
|
||||
// ArrowUp on first item should keep first selected
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount + 1)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-node-id][data-ghost]')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
drawStroke,
|
||||
hasCanvasContent,
|
||||
triggerSerialization
|
||||
} from '@e2e/helpers/painter'
|
||||
} from '@e2e/fixtures/utils/painter'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transform1)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
|
||||
await expect(cursor).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -187,7 +187,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width + 20,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
@@ -408,6 +411,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
// default 512 + slider step 64 = 576
|
||||
.toBe(576)
|
||||
})
|
||||
|
||||
@@ -493,6 +497,29 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should start empty'
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
await painterWidget
|
||||
.getByTestId('painter-clear-button')
|
||||
.dispatchEvent('click')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should still be empty after clearing empty canvas'
|
||||
})
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Serialization', () => {
|
||||
@@ -560,36 +587,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
|
||||
test.describe('Eraser', () => {
|
||||
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
@@ -604,18 +601,318 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
test.describe('Serialization — unchanged canvas', () => {
|
||||
test(
|
||||
'Unchanged canvas does not re-upload on second serialization',
|
||||
{ tag: '@slow' },
|
||||
async ({ comfyPage }) => {
|
||||
let uploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(uploadCount, 'first serialization should upload once').toBe(1)
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
expect(
|
||||
uploadCount,
|
||||
'second serialization without new drawing should not re-upload'
|
||||
).toBe(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Settings persistence', () => {
|
||||
test('Tool selection is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
|
||||
| string
|
||||
| undefined
|
||||
}),
|
||||
{ message: 'painterTool property should update to eraser' }
|
||||
)
|
||||
.toBe('eraser')
|
||||
})
|
||||
|
||||
test('Brush size change is saved to node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const sizeRow = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
.getByTestId('painter-size-row')
|
||||
const sizeSlider = sizeRow.getByRole('slider')
|
||||
|
||||
await expect(
|
||||
sizeRow.getByTestId('painter-size-value'),
|
||||
'brush size should start at default 20'
|
||||
).toHaveText('20')
|
||||
|
||||
await sizeSlider.focus()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sizeSlider.press('ArrowRight')
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
return graph?._nodes_by_id?.['1']?.properties
|
||||
?.painterBrushSize as number | undefined
|
||||
}),
|
||||
{ message: 'painterBrushSize property should update to 30' }
|
||||
)
|
||||
.toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
if (node) {
|
||||
node.size = [200, 400]
|
||||
window.app!.canvas.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
const hasContentAtRow = (yFraction: number) =>
|
||||
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cy = Math.floor(el.height * y)
|
||||
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
}, yFraction)
|
||||
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.25), {
|
||||
message: 'top stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.5), {
|
||||
message: 'middle stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.75), {
|
||||
message: 'bottom stroke should be present'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Painter — input image connection',
|
||||
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
||||
() => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
|
||||
})
|
||||
|
||||
test('Width, height, and bg_color controls hide when input is connected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-width-row'),
|
||||
'width row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-height-row'),
|
||||
'height row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-bg-color-row'),
|
||||
'background color row should be hidden when input is connected'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-dimension-text'),
|
||||
'dimension text should be visible when input is connected'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Canvas resizes to match input image dimensions after execution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{
|
||||
message: 'input image should be fully decoded',
|
||||
timeout: 30_000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas width should match input image natural width'
|
||||
})
|
||||
.toBe(nw)
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
|
||||
message: 'canvas height should match input image natural height'
|
||||
})
|
||||
.toBe(nh)
|
||||
})
|
||||
|
||||
test('Drawing over input image produces content on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const img = node.locator('.widget-expands img')
|
||||
await expect(
|
||||
img,
|
||||
'input image should appear after execution'
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
||||
),
|
||||
{ message: 'input image should be fully decoded', timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
||||
message: 'canvas should resize to match input image width',
|
||||
timeout: 15_000
|
||||
})
|
||||
.toBe(nw)
|
||||
|
||||
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
|
||||
// intercepts coordinate-based hit testing from page.mouse
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
const startX = box.x + box.width * 0.3
|
||||
const endX = box.x + box.width * 0.7
|
||||
const midY = box.y + box.height * 0.5
|
||||
const pointerOpts = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
isPrimary: true
|
||||
}
|
||||
await canvas.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: midY
|
||||
})
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await canvas.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + (endX - startX) * (i / 10),
|
||||
clientY: midY
|
||||
})
|
||||
}
|
||||
await canvas.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: endX,
|
||||
clientY: midY
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'drawing over input image should produce canvas content'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter'
|
||||
import {
|
||||
logMeasurement,
|
||||
recordMeasurement
|
||||
} from '@e2e/fixtures/utils/perfReporter'
|
||||
|
||||
test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
test('canvas idle style recalculations', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PropertiesPanelHelper {
|
||||
@@ -8,12 +9,14 @@ export class PropertiesPanelHelper {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly closeButton: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.closeButton = this.root.locator('button[aria-pressed]')
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
}
|
||||
|
||||
get tabs(): Locator {
|
||||
@@ -28,10 +31,6 @@ export class PropertiesPanelHelper {
|
||||
return this.panelTitle.locator('i[class*="lucide--pencil"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.root.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
|
||||
return this.root.locator('button', { hasText: state })
|
||||
}
|
||||
@@ -86,8 +85,8 @@ export class PropertiesPanelHelper {
|
||||
|
||||
async editTitle(newTitle: string): Promise<void> {
|
||||
await this.titleEditIcon.click()
|
||||
await this.titleInput.fill(newTitle)
|
||||
await this.titleInput.press('Enter')
|
||||
await this.titleEditor.expectVisible()
|
||||
await this.titleEditor.setTitle(newTitle)
|
||||
}
|
||||
|
||||
async searchWidgets(query: string): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
} from '@e2e/fixtures/utils/clipboardSpy'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
@@ -99,5 +99,58 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Properties panel - Title editing', () => {
|
||||
|
||||
test('should enter edit mode on pencil click', async () => {
|
||||
await panel.titleEditIcon.click()
|
||||
await expect(panel.titleInput).toBeVisible()
|
||||
await panel.titleEditor.expectVisible()
|
||||
})
|
||||
|
||||
test('should update node title on edit', async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
|
||||
test('Properties panel opens with workflow overview', async ({
|
||||
@@ -35,11 +34,8 @@ test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
|
||||
|
||||
// Click on the title to enter edit mode
|
||||
await propertiesPanel.panelTitle.click()
|
||||
const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput)
|
||||
await expect(titleInput).toBeVisible()
|
||||
|
||||
await titleInput.fill('My Custom Sampler')
|
||||
await titleInput.press('Enter')
|
||||
await propertiesPanel.titleEditor.expectVisible()
|
||||
await propertiesPanel.titleEditor.setTitle('My Custom Sampler')
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/utils/boundsUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
@@ -62,6 +65,37 @@ const SAMPLE_IMPORTED_FILES = [
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
const JOB_GAMMA_DETAIL: JobDetail = {
|
||||
...SAMPLE_JOBS[2],
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [
|
||||
{
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{
|
||||
filename: 'abstract_art_alt.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({
|
||||
mockCloudAssetSidebarData: async ({ comfyPage }, use) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockEmptyCloudAssets()
|
||||
|
||||
await use()
|
||||
|
||||
await comfyPage.assets.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
@@ -633,6 +667,96 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
cloudTest(
|
||||
'Single job selection uses preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected assets from one job use preserve naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect.poll(() => tab.assetCards.count()).toBe(2)
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual(
|
||||
['abstract_art.png', 'abstract_art_alt.png']
|
||||
)
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Multiple selected jobs use job-time naming strategy',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await tab.assetCards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('group_by_job_time')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
async function expectPromotedWidgetNamesToContain(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
|
||||
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
|
||||
@@ -1,39 +1,73 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
}
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -86,54 +120,434 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Legacy prefixed proxyWidget normalization', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
let previousUseNewMenu: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousUseNewMenu =
|
||||
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.UseNewMenu',
|
||||
previousUseNewMenu
|
||||
)
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
checkEndpoint(label, 'origin_id', link.origin_id, g),
|
||||
checkEndpoint(label, 'target_id', link.target_id, g)
|
||||
].filter((e): e is string => e !== null)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
|
||||
test.describe(
|
||||
'Vue Node Bring to Front',
|
||||
|
||||
@@ -66,10 +66,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
|
||||
await titleInput.waitFor({ state: 'visible' })
|
||||
await titleInput.fill('My Renamed Sampler')
|
||||
await titleInput.press('Enter')
|
||||
await comfyPage.titleEditor.expectVisible()
|
||||
await comfyPage.titleEditor.setTitle('My Renamed Sampler')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const renamedNode =
|
||||
|
||||
@@ -5,7 +5,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
|
||||
test('should display node title', async ({ comfyPage }) => {
|
||||
@@ -22,8 +21,8 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
|
||||
// Test cancel with Escape
|
||||
await vueNode.title.dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
await vueNode.titleInput.fill('This Should Be Cancelled')
|
||||
await vueNode.titleInput.press('Escape')
|
||||
await vueNode.titleEditor.input.fill('This Should Be Cancelled')
|
||||
await vueNode.titleEditor.cancel()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Title should remain as the previously saved value
|
||||
@@ -40,9 +39,6 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => {
|
||||
if (!nodeBbox) throw new Error('Node not found')
|
||||
await loadCheckpointNode.dblclick()
|
||||
|
||||
const editingTitleInput = comfyPage.page.getByTestId(
|
||||
TestIds.node.titleInput
|
||||
)
|
||||
await expect(editingTitleInput).toBeHidden()
|
||||
await comfyPage.titleEditor.expectHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,9 +285,12 @@ export default defineConfig([
|
||||
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
|
||||
}
|
||||
],
|
||||
// Tests routinely define stub and harness components side-by-side with the
|
||||
// system under test, which is a distinct use case from production SFCs.
|
||||
'vue/one-component-per-file': 'off'
|
||||
// Tests routinely define stub and harness components side-by-side with
|
||||
// the system under test and stub emits for documentation only — these
|
||||
// production-SFC rules are noise in a test file.
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/no-reserved-component-names': 'off',
|
||||
'vue/no-unused-emit-declarations': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -472,6 +475,11 @@ export default defineConfig([
|
||||
{
|
||||
group: ['./**', '../**'],
|
||||
message: 'Use the @e2e/ path alias instead of relative imports.'
|
||||
},
|
||||
{
|
||||
group: ['@e2e/helpers', '@e2e/helpers/*'],
|
||||
message:
|
||||
'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -490,6 +498,11 @@ export default defineConfig([
|
||||
{
|
||||
group: ['./**', '../**'],
|
||||
message: 'Use the @e2e/ path alias instead of relative imports.'
|
||||
},
|
||||
{
|
||||
group: ['@e2e/helpers', '@e2e/helpers/*'],
|
||||
message:
|
||||
'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ const config: KnipConfig = {
|
||||
entry: ['src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/design-system': {
|
||||
project: ['src/**/*.{css,js,ts}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.11",
|
||||
"version": "1.44.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -73,10 +73,6 @@
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
--color-coral-red-600: #973a40;
|
||||
--color-coral-red-500: #c53f49;
|
||||
--color-coral-red-400: #dd424e;
|
||||
|
||||
--color-bypass: #6a246a;
|
||||
--color-error: #962a2a;
|
||||
|
||||
@@ -589,8 +585,6 @@
|
||||
background-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
|
||||
4
packages/ingest-types/src/types.gen.ts
generated
4
packages/ingest-types/src/types.gen.ts
generated
@@ -3825,14 +3825,14 @@ export type CreateAssetExportData = {
|
||||
/**
|
||||
* Strategy for naming files in the ZIP:
|
||||
* - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png")
|
||||
* - prepend_job_id: Prepend job ID to filenames for uniqueness (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4_ComfyUI_00001_.png")
|
||||
* - group_by_job_time: Group assets by job execution time as parent directories
|
||||
* - preserve: Use original asset names, skip duplicates (first one wins)
|
||||
* - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png")
|
||||
*
|
||||
*/
|
||||
naming_strategy?:
|
||||
| 'group_by_job_id'
|
||||
| 'prepend_job_id'
|
||||
| 'group_by_job_time'
|
||||
| 'preserve'
|
||||
| 'asset_id'
|
||||
/**
|
||||
|
||||
2
packages/ingest-types/src/zod.gen.ts
generated
2
packages/ingest-types/src/zod.gen.ts
generated
@@ -1818,7 +1818,7 @@ export const zCreateAssetExportData = z.object({
|
||||
job_ids: z.array(z.string()).optional(),
|
||||
asset_ids: z.array(z.string()).optional(),
|
||||
naming_strategy: z
|
||||
.enum(['group_by_job_id', 'prepend_job_id', 'preserve', 'asset_id'])
|
||||
.enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id'])
|
||||
.optional(),
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
|
||||
}),
|
||||
|
||||
@@ -14495,7 +14495,7 @@ export interface components {
|
||||
* @description The ID of the model to call
|
||||
* @enum {string}
|
||||
*/
|
||||
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit";
|
||||
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit" | "happyhorse-1.0-t2v" | "happyhorse-1.0-i2v" | "happyhorse-1.0-r2v" | "happyhorse-1.0-video-edit";
|
||||
/** @description Enter basic information, such as prompt words, etc. */
|
||||
input: {
|
||||
/**
|
||||
|
||||
@@ -202,6 +202,28 @@ describe('formatUtil', () => {
|
||||
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
|
||||
)
|
||||
})
|
||||
|
||||
it('should highlight cross-word matches', () => {
|
||||
const result = highlightQuery('convert image to mask', 'geto', false)
|
||||
expect(result).toBe(
|
||||
'convert ima<span class="highlight">ge to</span> mask'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not match across line breaks', () => {
|
||||
const result = highlightQuery('ge\nto', 'geto', false)
|
||||
expect(result).toBe('ge\nto')
|
||||
})
|
||||
|
||||
it('should not match across tabs', () => {
|
||||
const result = highlightQuery('ge\tto', 'geto', false)
|
||||
expect(result).toBe('ge\tto')
|
||||
})
|
||||
|
||||
it('should not match across multiple spaces', () => {
|
||||
const result = highlightQuery('ge to', 'geto', false)
|
||||
expect(result).toBe('ge to')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilenameDetails', () => {
|
||||
|
||||
@@ -74,10 +74,14 @@ export function highlightQuery(
|
||||
text = DOMPurify.sanitize(text)
|
||||
}
|
||||
|
||||
// Escape special regex characters in the query string
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
// Escape special regex characters, then join with an optional single
|
||||
// space so cross-word matches (e.g. "geto" → "imaGE TO") are
|
||||
// highlighted without spanning tabs, newlines, or multi-space gaps.
|
||||
const pattern = Array.from(query)
|
||||
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('[ ]?')
|
||||
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
||||
const regex = new RegExp(`(${pattern})`, 'gi')
|
||||
return text.replace(regex, '<span class="highlight">$1</span>')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { clsx } from 'clsx'
|
||||
import type { ClassArray } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { extendTailwindMerge } from 'tailwind-merge'
|
||||
|
||||
export type { ClassValue } from 'clsx'
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function cn(...inputs: ClassArray) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const ScrubableNumberInputStub = defineComponent({
|
||||
step: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
/* eslint-disable vue/no-unused-emit-declarations */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable vue/no-reserved-component-names */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -100,7 +99,7 @@ const WidgetBoundingBoxStub = defineComponent({
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-emit-declarations
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
template: `<div data-testid="bbox-child"
|
||||
:data-disabled="String(disabled)"
|
||||
|
||||
254
src/components/load3d/Load3D.test.ts
Normal file
254
src/components/load3d/Load3D.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
|
||||
load3dState: {
|
||||
current: null as ReturnType<typeof buildLoad3dStub> | null
|
||||
},
|
||||
resolveNodeMock: vi.fn(),
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
function buildLoad3dStub() {
|
||||
return {
|
||||
sceneConfig: ref({}),
|
||||
modelConfig: ref({}),
|
||||
cameraConfig: ref({}),
|
||||
lightConfig: ref({}),
|
||||
isRecording: ref(false),
|
||||
isPreview: ref(false),
|
||||
canFitToViewer: ref(true),
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
hasSkeleton: ref(false),
|
||||
hasRecording: ref(false),
|
||||
recordingDuration: ref(0),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
selectedSpeed: ref(1),
|
||||
selectedAnimation: ref(0),
|
||||
animationProgress: ref(0),
|
||||
animationDuration: ref(0),
|
||||
loading: ref(false),
|
||||
loadingMessage: ref(''),
|
||||
initializeLoad3d: vi.fn(),
|
||||
handleMouseEnter: vi.fn(),
|
||||
handleMouseLeave: vi.fn(),
|
||||
handleStartRecording: vi.fn(),
|
||||
handleStopRecording: vi.fn(),
|
||||
handleExportRecording: vi.fn(),
|
||||
handleClearRecording: vi.fn(),
|
||||
handleSeek: vi.fn(),
|
||||
handleBackgroundImageUpdate: vi.fn(),
|
||||
handleHDRIFileUpdate: vi.fn(),
|
||||
handleExportModel: vi.fn(),
|
||||
handleModelDrop: vi.fn(),
|
||||
handleToggleGizmo: vi.fn(),
|
||||
handleSetGizmoMode: vi.fn(),
|
||||
handleResetGizmoTransform: vi.fn(),
|
||||
handleFitToViewer: vi.fn(),
|
||||
cleanup: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => load3dState.current
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNode: resolveNodeMock
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: { fitToViewer: 'Fit to viewer' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOptions = {
|
||||
widget?: unknown
|
||||
nodeId?: number | string
|
||||
stateOverrides?: Partial<ReturnType<typeof buildLoad3dStub>>
|
||||
enable3DViewer?: boolean
|
||||
}
|
||||
|
||||
const MOCK_NODE = { id: 'node', type: 'Load3D' }
|
||||
|
||||
function renderLoad3D(options: RenderOptions = {}) {
|
||||
const stub = buildLoad3dStub()
|
||||
if (options.stateOverrides) {
|
||||
Object.assign(stub, options.stateOverrides)
|
||||
}
|
||||
load3dState.current = stub
|
||||
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.3DViewerEnable'
|
||||
? (options.enable3DViewer ?? false)
|
||||
: undefined
|
||||
)
|
||||
|
||||
return {
|
||||
...render(Load3D, {
|
||||
props: {
|
||||
widget: (options.widget ?? {
|
||||
node: MOCK_NODE
|
||||
}) as unknown as ComponentWidget<string[]>,
|
||||
nodeId: options.nodeId
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Load3DControls: {
|
||||
name: 'Load3DControls',
|
||||
template: '<div data-testid="load3d-controls" />'
|
||||
},
|
||||
Load3DScene: {
|
||||
name: 'Load3DScene',
|
||||
template: '<div data-testid="load3d-scene" />'
|
||||
},
|
||||
AnimationControls: {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
RecordingControls: {
|
||||
name: 'RecordingControls',
|
||||
template: '<div data-testid="recording-controls" />'
|
||||
},
|
||||
ViewerControls: {
|
||||
name: 'ViewerControls',
|
||||
template: '<div data-testid="viewer-controls" />'
|
||||
},
|
||||
Button: {
|
||||
name: 'Button',
|
||||
props: ['ariaLabel'],
|
||||
template:
|
||||
'<button type="button" :aria-label="ariaLabel"><slot /></button>'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
}),
|
||||
stub
|
||||
}
|
||||
}
|
||||
|
||||
describe('Load3D', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
load3dState.current = null
|
||||
})
|
||||
|
||||
describe('node resolution', () => {
|
||||
it('uses widget.node when the widget is a ComponentWidget', () => {
|
||||
renderLoad3D({ widget: { node: MOCK_NODE } })
|
||||
|
||||
expect(screen.getByTestId('load3d-scene')).toBeInTheDocument()
|
||||
expect(resolveNodeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to resolveNode(nodeId) when the widget lacks a node', async () => {
|
||||
resolveNodeMock.mockReturnValue(MOCK_NODE)
|
||||
renderLoad3D({ widget: {}, nodeId: 42 })
|
||||
|
||||
expect(resolveNodeMock).toHaveBeenCalledWith(42)
|
||||
expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render Load3DScene when no node can be resolved', async () => {
|
||||
resolveNodeMock.mockReturnValue(null)
|
||||
renderLoad3D({ widget: {}, nodeId: 99 })
|
||||
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByTestId('load3d-scene')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capability-driven chrome', () => {
|
||||
it('shows the fit-to-viewer button when canFitToViewer is true', () => {
|
||||
renderLoad3D({ stateOverrides: { canFitToViewer: ref(true) } })
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Fit to viewer' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the fit-to-viewer button when canFitToViewer is false', () => {
|
||||
renderLoad3D({ stateOverrides: { canFitToViewer: ref(false) } })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Fit to viewer' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('invokes handleFitToViewer when the fit button is clicked', async () => {
|
||||
const { stub } = renderLoad3D()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fit to viewer' }))
|
||||
|
||||
expect(stub.handleFitToViewer).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer controls', () => {
|
||||
it('renders ViewerControls when the 3D viewer setting is enabled', () => {
|
||||
renderLoad3D({ enable3DViewer: true })
|
||||
expect(screen.getByTestId('viewer-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides ViewerControls when the 3D viewer setting is disabled', () => {
|
||||
renderLoad3D({ enable3DViewer: false })
|
||||
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides ViewerControls when there is no node even if the setting is on', () => {
|
||||
resolveNodeMock.mockReturnValue(null)
|
||||
renderLoad3D({ widget: {}, nodeId: 1, enable3DViewer: true })
|
||||
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recording controls', () => {
|
||||
it('renders RecordingControls in regular (non-preview) mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
|
||||
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides RecordingControls in preview mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
|
||||
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('animation controls', () => {
|
||||
it('renders AnimationControls when animations are present', () => {
|
||||
renderLoad3D({
|
||||
stateOverrides: {
|
||||
animations: ref([{ name: 'idle', index: 0 }])
|
||||
}
|
||||
})
|
||||
expect(screen.getByTestId('animation-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides AnimationControls when the animation list is empty', () => {
|
||||
renderLoad3D()
|
||||
expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,10 @@
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:can-use-gizmo="canUseGizmo"
|
||||
:can-use-lighting="canUseLighting"
|
||||
:can-export="canExport"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@@ -43,7 +45,10 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-auto absolute top-12 right-2 z-20">
|
||||
<div
|
||||
v-if="canFitToViewer"
|
||||
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="{
|
||||
@@ -138,8 +143,11 @@ const {
|
||||
// other state
|
||||
isRecording,
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
|
||||
404
src/components/load3d/Load3DControls.test.ts
Normal file
404
src/components/load3d/Load3DControls.test.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/composables/useDismissableOverlay', () => ({
|
||||
useDismissableOverlay: vi.fn()
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: { showMenu: 'Show menu' },
|
||||
load3d: {
|
||||
scene: 'Scene',
|
||||
model: 'Model',
|
||||
camera: 'Camera',
|
||||
light: 'Light',
|
||||
gizmo: { label: 'Gizmo' },
|
||||
export: 'Export'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const childStubs = {
|
||||
SceneControls: {
|
||||
name: 'SceneControls',
|
||||
emits: ['update-background-image'],
|
||||
template: `<div data-testid="scene-controls">
|
||||
<button data-testid="scene-emit-bg" @click="$emit('update-background-image', null)" />
|
||||
</div>`
|
||||
},
|
||||
ModelControls: {
|
||||
name: 'ModelControls',
|
||||
template: '<div data-testid="model-controls" />'
|
||||
},
|
||||
CameraControls: {
|
||||
name: 'CameraControls',
|
||||
template: '<div data-testid="camera-controls" />'
|
||||
},
|
||||
LightControls: {
|
||||
name: 'LightControls',
|
||||
template: '<div data-testid="light-controls" />'
|
||||
},
|
||||
HDRIControls: {
|
||||
name: 'HDRIControls',
|
||||
emits: ['update-hdri-file'],
|
||||
template: `<div data-testid="hdri-controls">
|
||||
<button data-testid="hdri-emit-file" @click="$emit('update-hdri-file', null)" />
|
||||
</div>`
|
||||
},
|
||||
ExportControls: {
|
||||
name: 'ExportControls',
|
||||
emits: ['export-model'],
|
||||
template: `<div data-testid="export-controls">
|
||||
<button data-testid="export-emit-glb" @click="$emit('export-model', 'glb')" />
|
||||
</div>`
|
||||
},
|
||||
GizmoControls: {
|
||||
name: 'GizmoControls',
|
||||
emits: ['toggle-gizmo', 'set-gizmo-mode', 'reset-gizmo-transform'],
|
||||
template: `<div data-testid="gizmo-controls">
|
||||
<button data-testid="gizmo-emit-toggle" @click="$emit('toggle-gizmo', true)" />
|
||||
<button data-testid="gizmo-emit-mode" @click="$emit('set-gizmo-mode', 'rotate')" />
|
||||
<button data-testid="gizmo-emit-reset" @click="$emit('reset-gizmo-transform')" />
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSceneConfig: SceneConfig = {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
}
|
||||
|
||||
const defaultModelConfig: ModelConfig = {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCameraConfig: CameraConfig = {
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
}
|
||||
|
||||
const defaultLightConfig: LightConfig = {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
|
||||
type RenderProps = {
|
||||
sceneConfig?: SceneConfig
|
||||
modelConfig?: ModelConfig
|
||||
cameraConfig?: CameraConfig
|
||||
lightConfig?: LightConfig
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
canExport?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
onExportModel?: (format: string) => void
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
onToggleGizmo?: (enabled: boolean) => void
|
||||
onSetGizmoMode?: (mode: string) => void
|
||||
onResetGizmoTransform?: () => void
|
||||
}
|
||||
|
||||
function renderControls(overrides: RenderProps = {}) {
|
||||
const result = render(Load3DControls, {
|
||||
props: {
|
||||
sceneConfig: defaultSceneConfig,
|
||||
modelConfig: defaultModelConfig,
|
||||
cameraConfig: defaultCameraConfig,
|
||||
lightConfig: defaultLightConfig,
|
||||
canUseGizmo: true,
|
||||
canUseLighting: true,
|
||||
canExport: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton: false,
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: childStubs,
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Show menu' }))
|
||||
}
|
||||
|
||||
describe('Load3DControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('category menu', () => {
|
||||
it('renders SceneControls by default', () => {
|
||||
renderControls()
|
||||
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the category menu closed until the trigger is clicked', async () => {
|
||||
const { user } = renderControls()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Scene' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await openMenu(user)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows every category when all capabilities are enabled', async () => {
|
||||
const { user } = renderControls()
|
||||
await openMenu(user)
|
||||
|
||||
for (const label of [
|
||||
'Scene',
|
||||
'Model',
|
||||
'Camera',
|
||||
'Light',
|
||||
'Gizmo',
|
||||
'Export'
|
||||
]) {
|
||||
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('omits the light category when canUseLighting is false', async () => {
|
||||
const { user } = renderControls({ canUseLighting: false })
|
||||
await openMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the gizmo category when canUseGizmo is false', async () => {
|
||||
const { user } = renderControls({ canUseGizmo: false })
|
||||
await openMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Gizmo' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the export category when canExport is false', async () => {
|
||||
const { user } = renderControls({ canExport: false })
|
||||
await openMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selecting a category closes the menu and swaps the visible control', async () => {
|
||||
const { user } = renderControls()
|
||||
await openMenu(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Model' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Scene' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-controls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('control visibility', () => {
|
||||
async function selectCategory(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
label: string
|
||||
) {
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Model', 'model-controls'],
|
||||
['Camera', 'camera-controls']
|
||||
])('%s category renders only %s', async (label, testId) => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, label)
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Light category renders both LightControls and HDRIControls', async () => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, 'Light')
|
||||
|
||||
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('hdri-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Gizmo category renders GizmoControls', async () => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, 'Gizmo')
|
||||
|
||||
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Export category renders ExportControls', async () => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, 'Export')
|
||||
|
||||
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides all controls when the corresponding v-model is undefined', () => {
|
||||
renderControls({
|
||||
sceneConfig: undefined,
|
||||
modelConfig: undefined,
|
||||
cameraConfig: undefined,
|
||||
lightConfig: undefined
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capability desync handling', () => {
|
||||
it('hides the active panel and resets to scene when its capability is dropped at runtime', async () => {
|
||||
const { user, rerender } = renderControls()
|
||||
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Light' }))
|
||||
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
||||
|
||||
await rerender({ canUseLighting: false })
|
||||
|
||||
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
||||
|
||||
await openMenu(user)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Gizmo', 'gizmo-controls', 'canUseGizmo' as const],
|
||||
['Export', 'export-controls', 'canExport' as const]
|
||||
])(
|
||||
'hides the %s panel when its capability flips off at runtime',
|
||||
async (label, testId, capabilityProp) => {
|
||||
const { user, rerender } = renderControls()
|
||||
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
|
||||
await rerender({ [capabilityProp]: false })
|
||||
|
||||
expect(screen.queryByTestId(testId)).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not reset activeCategory when capabilities change but the active one is still available', async () => {
|
||||
const { user, rerender } = renderControls()
|
||||
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Camera' }))
|
||||
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
|
||||
|
||||
await rerender({ canUseLighting: false, canUseGizmo: false })
|
||||
|
||||
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event forwarding', () => {
|
||||
it('forwards updateBackgroundImage from SceneControls', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderControls({ onUpdateBackgroundImage })
|
||||
|
||||
await user.click(screen.getByTestId('scene-emit-bg'))
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('forwards exportModel from ExportControls', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderControls({ onExportModel })
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
await user.click(screen.getByTestId('export-emit-glb'))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
|
||||
it('forwards updateHdriFile from HDRIControls', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { user } = renderControls({ onUpdateHdriFile })
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Light' }))
|
||||
|
||||
await user.click(screen.getByTestId('hdri-emit-file'))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('forwards gizmo events from GizmoControls', async () => {
|
||||
const onToggleGizmo = vi.fn()
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const onResetGizmoTransform = vi.fn()
|
||||
const { user } = renderControls({
|
||||
onToggleGizmo,
|
||||
onSetGizmoMode,
|
||||
onResetGizmoTransform
|
||||
})
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
await user.click(screen.getByTestId('gizmo-emit-toggle'))
|
||||
await user.click(screen.getByTestId('gizmo-emit-mode'))
|
||||
await user.click(screen.getByTestId('gizmo-emit-reset'))
|
||||
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
expect(onResetGizmoTransform).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -63,8 +63,7 @@
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
v-model:show-skeleton="modelConfig!.showSkeleton"
|
||||
:hide-material-mode="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
|
||||
@@ -105,7 +104,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
@@ -120,18 +119,23 @@ import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
isSplatModel = false,
|
||||
isPlyModel = false,
|
||||
canUseGizmo = true,
|
||||
canUseLighting = true,
|
||||
canExport = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
canExport?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
@@ -163,13 +167,23 @@ const categoryLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
if (isSplatModel) {
|
||||
return ['scene', 'model', 'camera']
|
||||
}
|
||||
|
||||
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
|
||||
const categories = ['scene', 'model', 'camera']
|
||||
if (canUseLighting) categories.push('light')
|
||||
if (canUseGizmo) categories.push('gizmo')
|
||||
if (canExport) categories.push('export')
|
||||
return categories
|
||||
})
|
||||
|
||||
watch(
|
||||
availableCategories,
|
||||
(categories) => {
|
||||
if (!categories.includes(activeCategory.value)) {
|
||||
activeCategory.value = 'scene'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const showSceneControls = computed(
|
||||
() => activeCategory.value === 'scene' && !!sceneConfig.value
|
||||
)
|
||||
@@ -181,13 +195,16 @@ const showCameraControls = computed(
|
||||
)
|
||||
const showLightControls = computed(
|
||||
() =>
|
||||
canUseLighting &&
|
||||
activeCategory.value === 'light' &&
|
||||
!!lightConfig.value &&
|
||||
!!modelConfig.value
|
||||
)
|
||||
const showExportControls = computed(() => activeCategory.value === 'export')
|
||||
const showExportControls = computed(
|
||||
() => canExport && activeCategory.value === 'export'
|
||||
)
|
||||
const showGizmoControls = computed(
|
||||
() => activeCategory.value === 'gizmo' && !!modelConfig.value
|
||||
() => canUseGizmo && activeCategory.value === 'gizmo' && !!modelConfig.value
|
||||
)
|
||||
|
||||
const toggleMenu = () => {
|
||||
|
||||
153
src/components/load3d/Load3DScene.test.ts
Normal file
153
src/components/load3d/Load3DScene.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
|
||||
const dragState = vi.hoisted(() => ({
|
||||
isDragging: null as Ref<boolean> | null,
|
||||
dragMessage: null as Ref<string> | null,
|
||||
handleDragOver: vi.fn(),
|
||||
handleDragLeave: vi.fn(),
|
||||
handleDrop: vi.fn(),
|
||||
capturedOptions: null as {
|
||||
onModelDrop?: (file: File) => Promise<void>
|
||||
disabled?: { value?: boolean } | boolean
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dDrag', () => ({
|
||||
useLoad3dDrag: (options: unknown) => {
|
||||
dragState.capturedOptions = options as typeof dragState.capturedOptions
|
||||
return {
|
||||
isDragging: dragState.isDragging!,
|
||||
dragMessage: dragState.dragMessage!,
|
||||
handleDragOver: dragState.handleDragOver,
|
||||
handleDragLeave: dragState.handleDragLeave,
|
||||
handleDrop: dragState.handleDrop
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
|
||||
default: {
|
||||
name: 'LoadingOverlayStub',
|
||||
props: ['loading', 'loadingMessage'],
|
||||
template: `
|
||||
<div data-testid="loading-overlay">
|
||||
<span v-if="loading">{{ loadingMessage }}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
type RenderOpts = {
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
isPreview?: boolean
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
initializeLoad3d?: (container: HTMLElement) => Promise<void>
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const initializeLoad3d =
|
||||
opts.initializeLoad3d ?? vi.fn().mockResolvedValue(undefined)
|
||||
const cleanup = opts.cleanup ?? vi.fn()
|
||||
|
||||
const utils = render(Load3DScene, {
|
||||
props: {
|
||||
initializeLoad3d,
|
||||
cleanup,
|
||||
loading: opts.loading ?? false,
|
||||
loadingMessage: opts.loadingMessage ?? '',
|
||||
onModelDrop: opts.onModelDrop,
|
||||
isPreview: opts.isPreview ?? false
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, initializeLoad3d, cleanup }
|
||||
}
|
||||
|
||||
describe('Load3DScene', () => {
|
||||
beforeEach(() => {
|
||||
dragState.isDragging = ref(false)
|
||||
dragState.dragMessage = ref('')
|
||||
dragState.handleDragOver.mockReset()
|
||||
dragState.handleDragLeave.mockReset()
|
||||
dragState.handleDrop.mockReset()
|
||||
dragState.capturedOptions = null
|
||||
})
|
||||
|
||||
it('renders the loading overlay child', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards loading + loadingMessage props to the overlay', () => {
|
||||
renderComponent({ loading: true, loadingMessage: 'Loading model…' })
|
||||
|
||||
expect(screen.getByText('Loading model…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls initializeLoad3d with the container element on mount', async () => {
|
||||
const initializeLoad3d = vi.fn().mockResolvedValue(undefined)
|
||||
renderComponent({ initializeLoad3d })
|
||||
|
||||
expect(initializeLoad3d).toHaveBeenCalledOnce()
|
||||
expect(initializeLoad3d.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
|
||||
})
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const cleanup = vi.fn()
|
||||
const { unmount } = renderComponent({ cleanup })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not render the drag overlay when not dragging', () => {
|
||||
dragState.isDragging!.value = false
|
||||
dragState.dragMessage!.value = 'Drop'
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Drop')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the drag overlay with the drag message while dragging in non-preview mode', () => {
|
||||
dragState.isDragging!.value = true
|
||||
dragState.dragMessage!.value = 'Drop to load model'
|
||||
renderComponent({ isPreview: false })
|
||||
|
||||
expect(screen.getByText('Drop to load model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the drag overlay even while dragging when in preview mode', () => {
|
||||
dragState.isDragging!.value = true
|
||||
dragState.dragMessage!.value = 'Drop to load model'
|
||||
renderComponent({ isPreview: true })
|
||||
|
||||
expect(screen.queryByText('Drop to load model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards a dropped file through useLoad3dDrag to the onModelDrop prop', async () => {
|
||||
const onModelDrop = vi.fn()
|
||||
renderComponent({ onModelDrop })
|
||||
|
||||
const file = new File(['m'], 'model.glb')
|
||||
await dragState.capturedOptions!.onModelDrop!(file)
|
||||
|
||||
expect(onModelDrop).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('does not throw when a file is dropped without an onModelDrop handler', async () => {
|
||||
renderComponent({ onModelDrop: undefined })
|
||||
|
||||
const file = new File(['m'], 'model.glb')
|
||||
await expect(
|
||||
dragState.capturedOptions!.onModelDrop!(file)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
361
src/components/load3d/Load3dViewerContent.test.ts
Normal file
361
src/components/load3d/Load3dViewerContent.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
class NoopMutationObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords(): MutationRecord[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
viewerState,
|
||||
dragState,
|
||||
capturedDragOptions,
|
||||
dialogCloseMock,
|
||||
serviceSourceLoad3d,
|
||||
getLoad3dAsyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
viewerState: {
|
||||
current: null as ReturnType<typeof buildViewerStub> | null
|
||||
},
|
||||
dragState: {
|
||||
current: null as ReturnType<typeof buildDragStub> | null
|
||||
},
|
||||
capturedDragOptions: {
|
||||
current: null as { onModelDrop?: (file: File) => Promise<void> } | null
|
||||
},
|
||||
dialogCloseMock: vi.fn(),
|
||||
serviceSourceLoad3d: {
|
||||
current: null as unknown
|
||||
},
|
||||
getLoad3dAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
function buildViewerStub() {
|
||||
return {
|
||||
backgroundColor: ref('#282828'),
|
||||
showGrid: ref(true),
|
||||
cameraType: ref('perspective'),
|
||||
fov: ref(75),
|
||||
lightIntensity: ref(1),
|
||||
backgroundImage: ref(''),
|
||||
hasBackgroundImage: ref(false),
|
||||
backgroundRenderMode: ref('tiled'),
|
||||
upDirection: ref('original'),
|
||||
materialMode: ref('original'),
|
||||
gizmoEnabled: ref(false),
|
||||
gizmoMode: ref('translate'),
|
||||
isPreview: ref(false),
|
||||
isStandaloneMode: ref(false),
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
selectedSpeed: ref(1),
|
||||
selectedAnimation: ref(0),
|
||||
animationProgress: ref(0),
|
||||
animationDuration: ref(0),
|
||||
initializeViewer: vi.fn().mockResolvedValue(undefined),
|
||||
initializeStandaloneViewer: vi.fn().mockResolvedValue(undefined),
|
||||
exportModel: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
handleMouseEnter: vi.fn(),
|
||||
handleMouseLeave: vi.fn(),
|
||||
restoreInitialState: vi.fn(),
|
||||
refreshViewport: vi.fn(),
|
||||
handleBackgroundImageUpdate: vi.fn(),
|
||||
handleModelDrop: vi.fn().mockResolvedValue(undefined),
|
||||
handleSeek: vi.fn(),
|
||||
resetGizmoTransform: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function buildDragStub() {
|
||||
return {
|
||||
isDragging: ref(false),
|
||||
dragMessage: ref(''),
|
||||
handleDragOver: vi.fn(),
|
||||
handleDragLeave: vi.fn(),
|
||||
handleDrop: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useLoad3dViewer', () => ({
|
||||
useLoad3dViewer: () => viewerState.current
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dDrag', () => ({
|
||||
useLoad3dDrag: (opts: { onModelDrop?: (file: File) => Promise<void> }) => {
|
||||
capturedDragOptions.current = opts
|
||||
return dragState.current
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({
|
||||
getOrCreateViewerSync: () => viewerState.current,
|
||||
getLoad3dAsync: getLoad3dAsyncMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: dialogCloseMock })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { cancel: 'Cancel' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOptions = {
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
viewerOverrides?: Partial<ReturnType<typeof buildViewerStub>>
|
||||
dragOverrides?: Partial<ReturnType<typeof buildDragStub>>
|
||||
}
|
||||
|
||||
const MOCK_NODE = createMockLGraphNode({ id: 'node-1', type: 'Load3D' })
|
||||
|
||||
async function renderViewerContent(options: RenderOptions = {}) {
|
||||
const viewerStub = buildViewerStub()
|
||||
if (options.viewerOverrides) {
|
||||
Object.assign(viewerStub, options.viewerOverrides)
|
||||
}
|
||||
viewerState.current = viewerStub
|
||||
|
||||
const dragStub = buildDragStub()
|
||||
if (options.dragOverrides) {
|
||||
Object.assign(dragStub, options.dragOverrides)
|
||||
}
|
||||
dragState.current = dragStub
|
||||
|
||||
getLoad3dAsyncMock.mockResolvedValue(serviceSourceLoad3d.current)
|
||||
|
||||
const result = render(Load3dViewerContent, {
|
||||
props: {
|
||||
node: options.node,
|
||||
modelUrl: options.modelUrl
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
AnimationControls: {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
CameraControls: {
|
||||
name: 'CameraControls',
|
||||
template: '<div data-testid="camera-controls" />'
|
||||
},
|
||||
ExportControls: {
|
||||
name: 'ExportControls',
|
||||
template: '<div data-testid="export-controls" />'
|
||||
},
|
||||
GizmoControls: {
|
||||
name: 'GizmoControls',
|
||||
template: '<div data-testid="gizmo-controls" />'
|
||||
},
|
||||
LightControls: {
|
||||
name: 'LightControls',
|
||||
template: '<div data-testid="light-controls" />'
|
||||
},
|
||||
ModelControls: {
|
||||
name: 'ModelControls',
|
||||
template: '<div data-testid="model-controls" />'
|
||||
},
|
||||
SceneControls: {
|
||||
name: 'SceneControls',
|
||||
template: '<div data-testid="scene-controls" />'
|
||||
},
|
||||
Button: {
|
||||
name: 'Button',
|
||||
template: '<button type="button"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
viewer: viewerStub,
|
||||
drag: dragStub,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('Load3dViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('MutationObserver', NoopMutationObserver)
|
||||
viewerState.current = null
|
||||
dragState.current = null
|
||||
capturedDragOptions.current = null
|
||||
serviceSourceLoad3d.current = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('invokes initializeStandaloneViewer when a modelUrl is provided without a node', async () => {
|
||||
const { viewer } = await renderViewerContent({
|
||||
modelUrl: 'api/view?filename=cube.glb'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(viewer.initializeStandaloneViewer).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
'api/view?filename=cube.glb'
|
||||
)
|
||||
)
|
||||
expect(viewer.initializeViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes initializeViewer with the source load3d when a node is provided', async () => {
|
||||
const source = { id: 'source-load3d' }
|
||||
serviceSourceLoad3d.current = source
|
||||
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(viewer.initializeViewer).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
source
|
||||
)
|
||||
)
|
||||
expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE)
|
||||
expect(viewer.initializeStandaloneViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips initializeViewer if the source load3d cannot be resolved', async () => {
|
||||
serviceSourceLoad3d.current = null
|
||||
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE)
|
||||
)
|
||||
expect(viewer.initializeViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capability gating', () => {
|
||||
it('hides LightControls when canUseLighting is false', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: { canUseLighting: ref(false) }
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides GizmoControls when canUseGizmo is false', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: { canUseGizmo: ref(false) }
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('gizmo-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides ExportControls when canExport is false', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: { canExport: ref(false) }
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('export-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all capability-gated controls when all flags are true', async () => {
|
||||
await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('animation controls', () => {
|
||||
it('hides AnimationControls when the animation list is empty', async () => {
|
||||
await renderViewerContent({ node: MOCK_NODE })
|
||||
expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows AnimationControls when animations are present', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: {
|
||||
animations: ref([{ name: 'idle', index: 0 }])
|
||||
}
|
||||
})
|
||||
expect(screen.getByTestId('animation-controls')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag overlay', () => {
|
||||
it('is hidden by default', async () => {
|
||||
await renderViewerContent({ node: MOCK_NODE })
|
||||
expect(screen.queryByText(/drag/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the drag message when useLoad3dDrag reports dragging', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
dragOverrides: {
|
||||
isDragging: ref(true),
|
||||
dragMessage: ref('Drop to load')
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Drop to load')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag integration', () => {
|
||||
it('routes a dropped file through useLoad3dDrag back to viewer.handleModelDrop', async () => {
|
||||
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
const file = new File(['cube'], 'cube.glb')
|
||||
|
||||
await capturedDragOptions.current!.onModelDrop!(file)
|
||||
|
||||
expect(viewer.handleModelDrop).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel button', () => {
|
||||
it('closes the dialog in node mode and restores initial viewer state', async () => {
|
||||
const { user, viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/ }))
|
||||
|
||||
expect(viewer.restoreInitialState).toHaveBeenCalledOnce()
|
||||
expect(dialogCloseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes the dialog in standalone mode without touching initial state', async () => {
|
||||
const { user, viewer } = await renderViewerContent({
|
||||
modelUrl: 'api/view?filename=cube.glb'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/ }))
|
||||
|
||||
expect(viewer.restoreInitialState).not.toHaveBeenCalled()
|
||||
expect(dialogCloseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -56,8 +56,7 @@
|
||||
<ModelControls
|
||||
v-model:up-direction="viewer.upDirection.value"
|
||||
v-model:material-mode="viewer.materialMode.value"
|
||||
:hide-material-mode="viewer.isSplatModel.value"
|
||||
:is-ply-model="viewer.isPlyModel.value"
|
||||
:material-modes="viewer.materialModes.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -68,13 +67,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<div v-if="viewer.canUseLighting.value" class="space-y-4 p-2">
|
||||
<LightControls
|
||||
v-model:light-intensity="viewer.lightIntensity.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div v-if="viewer.canUseGizmo.value" class="space-y-4 p-2">
|
||||
<GizmoControls
|
||||
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
|
||||
v-model:gizmo-mode="viewer.gizmoMode.value"
|
||||
@@ -82,7 +81,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
205
src/components/load3d/controls/AnimationControls.test.ts
Normal file
205
src/components/load3d/controls/AnimationControls.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { playPause: 'Play / pause' } } }
|
||||
})
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
type RenderOpts = {
|
||||
animations?: Animation[]
|
||||
playing?: boolean
|
||||
selectedSpeed?: number
|
||||
selectedAnimation?: number
|
||||
animationProgress?: number
|
||||
animationDuration?: number
|
||||
onSeek?: (progress: number) => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const animations = ref<Animation[]>(opts.animations ?? [])
|
||||
const playing = ref<boolean>(opts.playing ?? false)
|
||||
const selectedSpeed = ref<number>(opts.selectedSpeed ?? 1)
|
||||
const selectedAnimation = ref<number>(opts.selectedAnimation ?? 0)
|
||||
const animationProgress = ref<number>(opts.animationProgress ?? 0)
|
||||
const animationDuration = ref<number>(opts.animationDuration ?? 10)
|
||||
|
||||
const utils = render(AnimationControls, {
|
||||
props: {
|
||||
animations: animations.value,
|
||||
'onUpdate:animations': (v: Animation[] | undefined) => {
|
||||
if (v) animations.value = v
|
||||
},
|
||||
playing: playing.value,
|
||||
'onUpdate:playing': (v: boolean | undefined) => {
|
||||
if (v !== undefined) playing.value = v
|
||||
},
|
||||
selectedSpeed: selectedSpeed.value,
|
||||
'onUpdate:selectedSpeed': (v: number | undefined) => {
|
||||
if (v !== undefined) selectedSpeed.value = v
|
||||
},
|
||||
selectedAnimation: selectedAnimation.value,
|
||||
'onUpdate:selectedAnimation': (v: number | undefined) => {
|
||||
if (v !== undefined) selectedAnimation.value = v
|
||||
},
|
||||
animationProgress: animationProgress.value,
|
||||
'onUpdate:animationProgress': (v: number | undefined) => {
|
||||
if (v !== undefined) animationProgress.value = v
|
||||
},
|
||||
animationDuration: animationDuration.value,
|
||||
'onUpdate:animationDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) animationDuration.value = v
|
||||
},
|
||||
onSeek: opts.onSeek
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('AnimationControls', () => {
|
||||
it('renders nothing when the animation list is empty', () => {
|
||||
renderComponent({ animations: [] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Play / pause' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the play / speed / track / progress widgets when animations are present', () => {
|
||||
renderComponent({
|
||||
animations: [
|
||||
{ name: 'idle', index: 0 },
|
||||
{ name: 'walk', index: 1 }
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Play / pause' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2)
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flips playing to true via v-model when starting from a paused state', async () => {
|
||||
const { user, playing } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
playing: false
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
|
||||
|
||||
expect(playing.value).toBe(true)
|
||||
})
|
||||
|
||||
it('flips playing to false via v-model when starting from a playing state', async () => {
|
||||
const { user, playing } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
playing: true
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
|
||||
|
||||
expect(playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates animationProgress and emits seek with the new progress when the slider moves', () => {
|
||||
const onSeek = vi.fn()
|
||||
const { animationProgress } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationProgress: 0,
|
||||
onSeek
|
||||
})
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '37.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(animationProgress.value).toBe(37.5)
|
||||
expect(onSeek).toHaveBeenCalledWith(37.5)
|
||||
})
|
||||
|
||||
it('formats the time display under one minute as Ns', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 30,
|
||||
animationProgress: 50 // half of 30s = 15s
|
||||
})
|
||||
|
||||
expect(screen.getByText('15.0s / 30.0s')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats the time display over one minute as M:SS.S', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 90,
|
||||
animationProgress: 50 // half of 90s = 45s, total 1:30.0
|
||||
})
|
||||
|
||||
expect(screen.getByText('45.0s / 1:30.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows 0s for currentTime when animationDuration is 0', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 0,
|
||||
animationProgress: 50
|
||||
})
|
||||
|
||||
expect(screen.getByText('0.0s / 0.0s')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
84
src/components/load3d/controls/CameraControls.test.ts
Normal file
84
src/components/load3d/controls/CameraControls.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
|
||||
default: {
|
||||
name: 'PopupSlider',
|
||||
props: ['tooltipText', 'modelValue'],
|
||||
template: '<div data-testid="popup-slider">{{ tooltipText }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { load3d: { switchCamera: 'Switch camera', fov: 'FOV' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
|
||||
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
|
||||
const fov = ref<number>(initial.fov ?? 75)
|
||||
|
||||
const utils = render(CameraControls, {
|
||||
props: {
|
||||
cameraType: cameraType.value,
|
||||
'onUpdate:cameraType': (v: CameraType | undefined) => {
|
||||
if (v) cameraType.value = v
|
||||
},
|
||||
fov: fov.value,
|
||||
'onUpdate:fov': (v: number | undefined) => {
|
||||
if (v !== undefined) fov.value = v
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, cameraType, fov, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('CameraControls', () => {
|
||||
it('renders the switch-camera button', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Switch camera' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the FOV PopupSlider only for the perspective camera', () => {
|
||||
renderComponent({ type: 'perspective' })
|
||||
expect(screen.getByTestId('popup-slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the FOV PopupSlider for the orthographic camera', () => {
|
||||
renderComponent({ type: 'orthographic' })
|
||||
expect(screen.queryByTestId('popup-slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles cameraType from perspective to orthographic when the button is clicked', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'perspective' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
|
||||
|
||||
expect(cameraType.value).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('toggles cameraType from orthographic to perspective when the button is clicked', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'orthographic' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
|
||||
|
||||
expect(cameraType.value).toBe('perspective')
|
||||
})
|
||||
})
|
||||
78
src/components/load3d/controls/ExportControls.test.ts
Normal file
78
src/components/load3d/controls/ExportControls.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { load3d: { exportModel: 'Export model' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ExportControls', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the trigger button without exposing the format list initially', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export model' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'GLB' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals all three export format buttons when the trigger is clicked', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
|
||||
for (const label of ['GLB', 'OBJ', 'STL']) {
|
||||
expect(screen.getByRole('button', { name: label })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits exportModel with the chosen format and hides the popup', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
await user.click(screen.getByRole('button', { name: 'OBJ' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('obj')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'GLB' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the popup when a click happens outside the trigger', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
expect(screen.getByRole('button', { name: 'GLB' })).toBeVisible()
|
||||
|
||||
await user.click(document.body)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'GLB' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
197
src/components/load3d/controls/HDRIControls.test.ts
Normal file
197
src/components/load3d/controls/HDRIControls.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden file input has no role/label, queried by selector */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
|
||||
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const addAlert = vi.fn()
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
hdri: {
|
||||
label: 'HDRI',
|
||||
uploadFile: 'Upload HDRI',
|
||||
changeFile: 'Change HDRI',
|
||||
showAsBackground: 'Show as background',
|
||||
removeFile: 'Remove HDRI'
|
||||
}
|
||||
},
|
||||
toastMessages: { unsupportedHDRIFormat: 'Unsupported HDRI format' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const defaultConfig: HDRIConfig = {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
|
||||
type RenderOpts = {
|
||||
config?: HDRIConfig
|
||||
hasBackgroundImage?: boolean
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const config = ref<HDRIConfig>(opts.config ?? { ...defaultConfig })
|
||||
|
||||
const utils = render(HDRIControls, {
|
||||
props: {
|
||||
hdriConfig: config.value,
|
||||
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
|
||||
if (v) config.value = v
|
||||
},
|
||||
hasBackgroundImage: opts.hasBackgroundImage ?? false,
|
||||
onUpdateHdriFile: opts.onUpdateHdriFile
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, config, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('HDRIControls', () => {
|
||||
beforeEach(() => {
|
||||
addAlert.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('initial render', () => {
|
||||
it('renders the upload button when no HDRI is loaded', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Upload HDRI' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'HDRI' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the change / toggle / show-as-bg / remove buttons when an HDRI is loaded', () => {
|
||||
renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Change HDRI' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'HDRI' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show as background' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove HDRI' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the entire control when a background image is set and no HDRI is loaded', () => {
|
||||
const { container } = renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
config: { ...defaultConfig, hdriPath: '' }
|
||||
})
|
||||
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('still renders when a background image is set but an HDRI is loaded', () => {
|
||||
renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Change HDRI' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle buttons', () => {
|
||||
it('flips enabled in the v-model when the HDRI button is clicked', async () => {
|
||||
const { user, config } = renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'HDRI' }))
|
||||
|
||||
expect(config.value.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('flips showAsBackground in the v-model when the show-as-background button is clicked', async () => {
|
||||
const { user, config } = renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show as background' })
|
||||
)
|
||||
|
||||
expect(config.value.showAsBackground).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('file events', () => {
|
||||
it('emits updateHdriFile(null) when the remove button is clicked', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' },
|
||||
onUpdateHdriFile
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove HDRI' }))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('emits updateHdriFile with the picked file when its extension is supported', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { container } = renderComponent({ onUpdateHdriFile })
|
||||
|
||||
const fileInput = container.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement
|
||||
const file = new File(['hdri-data'], 'sky.hdr', {
|
||||
type: 'application/octet-stream'
|
||||
})
|
||||
Object.defineProperty(fileInput, 'files', { value: [file] })
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(file)
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unsupported file extensions with a toast and no emit', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { container } = renderComponent({ onUpdateHdriFile })
|
||||
|
||||
const fileInput = container.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
Object.defineProperty(fileInput, 'files', { value: [file] })
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(onUpdateHdriFile).not.toHaveBeenCalled()
|
||||
expect(addAlert).toHaveBeenCalledWith('Unsupported HDRI format')
|
||||
})
|
||||
})
|
||||
})
|
||||
193
src/components/load3d/controls/LightControls.test.ts
Normal file
193
src/components/load3d/controls/LightControls.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
MaterialMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const settingValues: Record<string, unknown> = {
|
||||
'Comfy.Load3D.LightIntensityMaximum': 10,
|
||||
'Comfy.Load3D.LightIntensityMinimum': 1,
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': 0.5
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settingValues[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDismissableOverlay', () => ({
|
||||
useDismissableOverlay: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { load3d: { lightIntensity: 'Light intensity' } } }
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
lightIntensity?: number
|
||||
materialMode?: MaterialMode
|
||||
hdriConfig?: HDRIConfig
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const lightIntensity = ref<number>(opts.lightIntensity ?? 5)
|
||||
const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original')
|
||||
const hdriConfig = ref<HDRIConfig | undefined>(opts.hdriConfig)
|
||||
|
||||
const utils = render(LightControls, {
|
||||
props: {
|
||||
lightIntensity: lightIntensity.value,
|
||||
'onUpdate:lightIntensity': (v: number | undefined) => {
|
||||
if (v !== undefined) lightIntensity.value = v
|
||||
},
|
||||
materialMode: materialMode.value,
|
||||
'onUpdate:materialMode': (v: MaterialMode | undefined) => {
|
||||
if (v) materialMode.value = v
|
||||
},
|
||||
hdriConfig: hdriConfig.value,
|
||||
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
|
||||
hdriConfig.value = v
|
||||
},
|
||||
embedded: opts.embedded ?? false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
lightIntensity,
|
||||
hdriConfig,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('LightControls', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('material mode gating', () => {
|
||||
it('renders the intensity control when materialMode is original', () => {
|
||||
renderComponent({ materialMode: 'original' })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Light intensity' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['normal', 'wireframe'] as const)(
|
||||
'hides the intensity control when materialMode is %s',
|
||||
(mode) => {
|
||||
renderComponent({ materialMode: mode })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('default (non-HDRI) mode', () => {
|
||||
it('feeds the slider with the setting-store min / max / step', async () => {
|
||||
const { user } = renderComponent({ lightIntensity: 5 })
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('10')
|
||||
expect(slider.step).toBe('0.5')
|
||||
})
|
||||
|
||||
it('updates lightIntensity v-model when the slider changes', async () => {
|
||||
const { user, lightIntensity } = renderComponent({ lightIntensity: 5 })
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '7.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(lightIntensity.value).toBe(7.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HDRI active mode', () => {
|
||||
const hdriConfig: HDRIConfig = {
|
||||
enabled: true,
|
||||
hdriPath: '/api/hdri/test.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 2
|
||||
}
|
||||
|
||||
it('reads the slider min / max / step from the HDRI range (0..5 step 0.1)', async () => {
|
||||
const { user } = renderComponent({ hdriConfig })
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('0')
|
||||
expect(slider.max).toBe('5')
|
||||
expect(slider.step).toBe('0.1')
|
||||
})
|
||||
|
||||
it('writes back to hdriConfig.intensity instead of lightIntensity when the slider changes', async () => {
|
||||
const {
|
||||
user,
|
||||
lightIntensity,
|
||||
hdriConfig: cfg
|
||||
} = renderComponent({
|
||||
lightIntensity: 5,
|
||||
hdriConfig
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '3.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(cfg.value?.intensity).toBe(3.5)
|
||||
expect(lightIntensity.value).toBe(5) // unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('embedded mode', () => {
|
||||
it('renders the slider inline without the trigger button when embedded is true', () => {
|
||||
renderComponent({ embedded: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user