mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
21 Commits
refactor/e
...
feat/snack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d71b012e4 | ||
|
|
a9695a7e1a | ||
|
|
719de7c828 | ||
|
|
e7640d414b | ||
|
|
c168c37c94 | ||
|
|
089051824c | ||
|
|
517da289f6 | ||
|
|
98c327b3c6 | ||
|
|
fc2a4e82cf | ||
|
|
e48d33e4c0 | ||
|
|
967f1eb562 | ||
|
|
8b83559402 | ||
|
|
bc11b5ff5e | ||
|
|
8c1ea7ae64 | ||
|
|
69e68847d9 | ||
|
|
fad9cf0db7 | ||
|
|
d532fcf779 | ||
|
|
52e73f2697 | ||
|
|
c4043637d6 | ||
|
|
9d61b4df06 | ||
|
|
963a7bf178 |
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -140,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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,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
|
||||
@@ -206,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)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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
|
||||
@@ -8,24 +14,91 @@ export class ComfyNodeSearchBoxV2 {
|
||||
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.getByRole('combobox')
|
||||
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
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' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -55,29 +55,32 @@ export class NodeOperationsHelper {
|
||||
* 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 cursorPosition is provided, a synthetic MouseEvent is created
|
||||
* as the dragEvent.
|
||||
* @param cursorPosition - Client coordinates for ghost placement dragEvent
|
||||
* 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'>,
|
||||
cursorPosition?: Position
|
||||
position?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, cursor]) => {
|
||||
([nodeType, opts, pos]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && cursor) {
|
||||
if (opts?.ghost && pos) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
clientX: cursor.x,
|
||||
clientY: cursor.y
|
||||
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 ?? {}, cursorPosition ?? null] as const
|
||||
[type, options ?? {}, position ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
@@ -86,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'
|
||||
@@ -249,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
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -201,12 +201,10 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
'subgraph blueprint added from search box enters ghost mode',
|
||||
{ tag: ['@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
|
||||
// Convert a node to a subgraph and publish it as a blueprint
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
@@ -231,9 +229,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Open v2 search box and search for the published blueprint
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.input.fill(blueprintName)
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
@@ -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,16 +27,12 @@ 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)
|
||||
@@ -63,12 +46,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
])
|
||||
await searchBoxV2.reload(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.filterBarButton('Bookmarked').click()
|
||||
await searchBoxV2.open()
|
||||
await searchBoxV2.rootCategoryButton('favorites').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
@@ -79,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,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.filterSearch.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()
|
||||
})
|
||||
})
|
||||
@@ -122,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.filterSearch.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'] }, () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
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/helpers/promotedWidgets'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -475,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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -493,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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
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 = () => {
|
||||
|
||||
360
src/components/load3d/Load3dViewerContent.test.ts
Normal file
360
src/components/load3d/Load3dViewerContent.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
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'
|
||||
|
||||
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 = { id: 'node-1', type: 'Load3D' } as unknown as LGraphNode
|
||||
|
||||
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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode" class="show-material-mode relative">
|
||||
<div v-if="materialModes.length > 0" class="show-material-mode relative">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.materialMode'),
|
||||
@@ -93,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -105,12 +105,10 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
@@ -131,22 +129,6 @@ const upDirections: UpDirection[] = [
|
||||
'+z'
|
||||
]
|
||||
|
||||
const materialModes = computed(() => {
|
||||
const modes: MaterialMode[] = [
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
//'depth' disable for now
|
||||
]
|
||||
|
||||
// Only show pointCloud mode for PLY files (point cloud rendering)
|
||||
if (isPlyModel) {
|
||||
modes.splice(1, 0, 'pointCloud')
|
||||
}
|
||||
|
||||
return modes
|
||||
})
|
||||
|
||||
function toggleUpDirection() {
|
||||
showUpDirection.value = !showUpDirection.value
|
||||
showMaterialMode.value = false
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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 ViewerModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
upDirection: 'Up direction',
|
||||
materialMode: 'Material mode',
|
||||
upDirections: { original: 'Original' },
|
||||
materialModes: {
|
||||
original: 'Original',
|
||||
normal: 'Normal',
|
||||
wireframe: 'Wireframe',
|
||||
pointCloud: 'Point Cloud',
|
||||
depth: 'Depth'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderProps = {
|
||||
upDirection?: UpDirection
|
||||
materialMode?: MaterialMode
|
||||
materialModes?: readonly MaterialMode[]
|
||||
'onUpdate:upDirection'?: (value: UpDirection | undefined) => void
|
||||
'onUpdate:materialMode'?: (value: MaterialMode | undefined) => void
|
||||
}
|
||||
|
||||
function renderControls(overrides: RenderProps = {}) {
|
||||
const result = render(ViewerModelControls, {
|
||||
props: {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
function getOptions(select: HTMLElement) {
|
||||
return Array.from(select.querySelectorAll('option'))
|
||||
}
|
||||
|
||||
describe('ViewerModelControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders both up direction and material mode selects by default', () => {
|
||||
renderControls()
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2)
|
||||
expect(screen.getByText('Up direction')).toBeInTheDocument()
|
||||
expect(screen.getByText('Material mode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the material mode select when materialModes is empty', () => {
|
||||
renderControls({ materialModes: [] })
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1)
|
||||
expect(screen.queryByText('Material mode')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('up direction options', () => {
|
||||
it('exposes the seven supported directions', () => {
|
||||
renderControls()
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(upDirectionSelect)
|
||||
|
||||
expect(options.map((o) => o.getAttribute('value'))).toEqual([
|
||||
'original',
|
||||
'-x',
|
||||
'+x',
|
||||
'-y',
|
||||
'+y',
|
||||
'-z',
|
||||
'+z'
|
||||
])
|
||||
})
|
||||
|
||||
it('localizes the "original" option label and uses raw axis labels for the rest', () => {
|
||||
renderControls()
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(upDirectionSelect)
|
||||
|
||||
expect(options.map((o) => o.textContent?.trim())).toEqual([
|
||||
'Original',
|
||||
'-X',
|
||||
'+X',
|
||||
'-Y',
|
||||
'+Y',
|
||||
'-Z',
|
||||
'+Z'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('material mode options', () => {
|
||||
it('emits one option per materialModes entry with localized labels', () => {
|
||||
renderControls({ materialModes: ['original', 'normal', 'wireframe'] })
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(materialModeSelect)
|
||||
|
||||
expect(options.map((o) => o.getAttribute('value'))).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
expect(options.map((o) => o.textContent?.trim())).toEqual([
|
||||
'Original',
|
||||
'Normal',
|
||||
'Wireframe'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes pointCloud when the adapter exposes it (PLY)', () => {
|
||||
renderControls({
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
|
||||
})
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(materialModeSelect)
|
||||
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options[1].textContent?.trim()).toBe('Point Cloud')
|
||||
expect(options[1].getAttribute('value')).toBe('pointCloud')
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model binding', () => {
|
||||
it('renders the initial upDirection as the selected option', () => {
|
||||
renderControls({ upDirection: '-z' })
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
expect((upDirectionSelect as HTMLSelectElement).value).toBe('-z')
|
||||
})
|
||||
|
||||
it('renders the initial materialMode as the selected option', () => {
|
||||
renderControls({ materialMode: 'normal' })
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
expect((materialModeSelect as HTMLSelectElement).value).toBe('normal')
|
||||
})
|
||||
|
||||
it('emits update:upDirection when a new direction is chosen', async () => {
|
||||
const listener = vi.fn()
|
||||
const { user } = renderControls({ 'onUpdate:upDirection': listener })
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
|
||||
await user.selectOptions(upDirectionSelect, '+x')
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('+x')
|
||||
})
|
||||
|
||||
it('emits update:materialMode when a new mode is chosen', async () => {
|
||||
const listener = vi.fn()
|
||||
const { user } = renderControls({ 'onUpdate:materialMode': listener })
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
|
||||
await user.selectOptions(materialModeSelect, 'wireframe')
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('wireframe')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
|
||||
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
@@ -33,9 +33,8 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
const { materialModes = ['original', 'normal', 'wireframe'] } = defineProps<{
|
||||
materialModes?: readonly MaterialMode[]
|
||||
}>()
|
||||
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
@@ -51,23 +50,10 @@ const upDirectionOptions = [
|
||||
{ label: '+Z', value: '+z' }
|
||||
]
|
||||
|
||||
const materialModeOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('load3d.materialModes.original'), value: 'original' }
|
||||
]
|
||||
|
||||
if (isPlyModel) {
|
||||
options.push({
|
||||
label: t('load3d.materialModes.pointCloud'),
|
||||
value: 'pointCloud'
|
||||
})
|
||||
}
|
||||
|
||||
options.push(
|
||||
{ label: t('load3d.materialModes.normal'), value: 'normal' },
|
||||
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
|
||||
)
|
||||
|
||||
return options
|
||||
})
|
||||
const materialModeOptions = computed(() =>
|
||||
materialModes.map((mode) => ({
|
||||
label: t(`load3d.materialModes.${mode}`),
|
||||
value: mode
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -8,17 +8,15 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionPolicy'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isRecommendedWidget
|
||||
} from '@/core/graph/subgraph/promotionPolicy'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -1,59 +1,34 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s]))
|
||||
|
||||
const { addNodeOnGraph } = vi.hoisted(() => ({
|
||||
addNodeOnGraph: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
getCanvasCenter: vi.fn(() => [0, 0]),
|
||||
addNodeOnGraph: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: null,
|
||||
getCanvas: vi.fn(() => ({
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
renderLinks: []
|
||||
}
|
||||
}))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeSearchService: {
|
||||
nodeFilters: [],
|
||||
inputTypeFilter: {},
|
||||
outputTypeFilter: {}
|
||||
}
|
||||
addNodeOnGraph
|
||||
})
|
||||
}))
|
||||
|
||||
type EmitAddFilter = (
|
||||
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
) => void
|
||||
type EmitAddNode = (nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) => void
|
||||
|
||||
function createFilter(
|
||||
id: string,
|
||||
@@ -72,26 +47,48 @@ describe('NodeSearchBoxPopover', () => {
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
function renderComponent(settings: Partial<Settings> = {}) {
|
||||
let emitAddFilter: EmitAddFilter | null = null
|
||||
let emitAddNodeV1: EmitAddNode | null = null
|
||||
let emitAddNodeV2: EmitAddNode | null = null
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['addFilter'],
|
||||
emits: ['addFilter', 'addNode'],
|
||||
setup(props, { emit }) {
|
||||
emitAddFilter = (filter) => emit('addFilter', filter)
|
||||
emitAddNodeV1 = (nodeDef, dragEvent) =>
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
const filterCount = computed(() => props.filters.length)
|
||||
return { filterCount }
|
||||
},
|
||||
template: '<output aria-label="filter count">{{ filterCount }}</output>'
|
||||
})
|
||||
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
emitAddNodeV2 = (nodeDef, dragEvent) =>
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
setting: {
|
||||
settingValues: settings,
|
||||
settingsById: coreSettingsById
|
||||
},
|
||||
searchBox: { visible: false }
|
||||
}
|
||||
})
|
||||
@@ -101,6 +98,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
plugins: [i18n, PrimeVue, pinia],
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
NodeSearchContent: NodeSearchContentStub,
|
||||
NodePreviewCard: true,
|
||||
Dialog: {
|
||||
template: '<div><slot name="container" /></div>',
|
||||
props: ['visible', 'modal', 'dismissableMask', 'pt']
|
||||
@@ -109,14 +108,34 @@ describe('NodeSearchBoxPopover', () => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
|
||||
|
||||
return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter }
|
||||
return {
|
||||
...result,
|
||||
get emitAddFilter() {
|
||||
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
|
||||
return emitAddFilter
|
||||
},
|
||||
get emitAddNodeV1() {
|
||||
if (!emitAddNodeV1) throw new Error('NodeSearchBox stub did not mount')
|
||||
return emitAddNodeV1
|
||||
},
|
||||
get emitAddNodeV2() {
|
||||
if (!emitAddNodeV2)
|
||||
throw new Error('NodeSearchContent stub did not mount')
|
||||
return emitAddNodeV2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addNodeOnGraph.mockReset()
|
||||
addNodeOnGraph.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('addFilter duplicate prevention', () => {
|
||||
it('should add a filter when no duplicates exist', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const { emitAddFilter } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
|
||||
})
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
@@ -125,7 +144,9 @@ describe('NodeSearchBoxPopover', () => {
|
||||
})
|
||||
|
||||
it('should not add a duplicate filter with same id and value', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const { emitAddFilter } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
|
||||
})
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
@@ -136,7 +157,9 @@ describe('NodeSearchBoxPopover', () => {
|
||||
})
|
||||
|
||||
it('should allow filters with same id but different values', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const { emitAddFilter } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
|
||||
})
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
@@ -147,7 +170,9 @@ describe('NodeSearchBoxPopover', () => {
|
||||
})
|
||||
|
||||
it('should allow filters with different ids but same value', async () => {
|
||||
const { emitAddFilter } = renderComponent()
|
||||
const { emitAddFilter } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)'
|
||||
})
|
||||
|
||||
emitAddFilter(createFilter('outputType', 'IMAGE'))
|
||||
await nextTick()
|
||||
@@ -157,4 +182,98 @@ describe('NodeSearchBoxPopover', () => {
|
||||
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addNode ghost flag (FollowCursor setting)', () => {
|
||||
const nodeDef = { name: 'KSampler' } as ComfyNodeDefImpl
|
||||
|
||||
it('should default ghost to true when v2 search is active and FollowCursor is unset', async () => {
|
||||
const { emitAddNodeV2 } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default'
|
||||
})
|
||||
emitAddNodeV2(nodeDef)
|
||||
await nextTick()
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDef,
|
||||
expect.objectContaining({ pos: expect.any(Array) }),
|
||||
expect.objectContaining({ ghost: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass ghost: true when v2 search is active and FollowCursor is enabled', async () => {
|
||||
const { emitAddNodeV2 } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default',
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor': true
|
||||
})
|
||||
emitAddNodeV2(nodeDef)
|
||||
await nextTick()
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDef,
|
||||
expect.objectContaining({ pos: expect.any(Array) }),
|
||||
expect.objectContaining({ ghost: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass ghost: false when v2 search is active but FollowCursor is disabled', async () => {
|
||||
const { emitAddNodeV2 } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default',
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor': false
|
||||
})
|
||||
emitAddNodeV2(nodeDef)
|
||||
await nextTick()
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDef,
|
||||
expect.objectContaining({ pos: expect.any(Array) }),
|
||||
expect.objectContaining({ ghost: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass ghost: false when v1 legacy search box is used', async () => {
|
||||
const { emitAddNodeV1 } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'v1 (legacy)',
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor': true
|
||||
})
|
||||
emitAddNodeV1(nodeDef)
|
||||
await nextTick()
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDef,
|
||||
expect.objectContaining({ pos: expect.any(Array) }),
|
||||
expect.objectContaining({ ghost: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass ghost: false when litegraph legacy search box is used', async () => {
|
||||
const { emitAddNodeV1 } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'litegraph (legacy)',
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor': true
|
||||
})
|
||||
emitAddNodeV1(nodeDef)
|
||||
await nextTick()
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDef,
|
||||
expect.objectContaining({ pos: expect.any(Array) }),
|
||||
expect.objectContaining({ ghost: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('should forward the dragEvent through to addNodeOnGraph', async () => {
|
||||
const dragEvent = new MouseEvent('mousedown')
|
||||
const { emitAddNodeV2 } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default',
|
||||
'Comfy.NodeSearchBoxImpl.FollowCursor': true
|
||||
})
|
||||
emitAddNodeV2(nodeDef, dragEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDef,
|
||||
expect.objectContaining({ pos: expect.any(Array) }),
|
||||
expect.objectContaining({ ghost: true, dragEvent })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,10 +129,11 @@ function closeDialog() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value, dragEvent }
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="displayedResults.length === 0"
|
||||
data-testid="no-results"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
@@ -24,7 +25,11 @@
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button type="button" :class="chipClass(false, tf.values.length > 0)">
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
>
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
@@ -57,8 +62,8 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -96,20 +101,20 @@ const MAX_VISIBLE_DOTS = 4
|
||||
const categoryButtons = computed(() => {
|
||||
const buttons: { id: string; label: string }[] = []
|
||||
if (hasFavorites) {
|
||||
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
|
||||
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
|
||||
}
|
||||
if (hasBlueprintNodes) {
|
||||
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
|
||||
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
|
||||
}
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
|
||||
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
|
||||
}
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: 'essentials', label: t('g.essentials') })
|
||||
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
|
||||
}
|
||||
buttons.push({ id: 'comfy', label: t('g.comfy') })
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasCustomNodes) {
|
||||
buttons.push({ id: 'custom', label: t('g.extensions') })
|
||||
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
|
||||
}
|
||||
return buttons
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
/>
|
||||
<span
|
||||
v-if="showIdName"
|
||||
data-testid="node-id-badge"
|
||||
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
v-html="highlightQuery(nodeDef.name, currentQuery)"
|
||||
/>
|
||||
|
||||
12
src/components/searchbox/v2/rootCategories.ts
Normal file
12
src/components/searchbox/v2/rootCategories.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
|
||||
|
||||
export const RootCategory = {
|
||||
Favorites: 'favorites',
|
||||
Comfy: 'comfy',
|
||||
Custom: 'custom',
|
||||
Essentials: 'essentials',
|
||||
PartnerNodes: 'partner-nodes',
|
||||
Blueprint: BLUEPRINT_CATEGORY
|
||||
} as const
|
||||
|
||||
export type RootCategoryId = (typeof RootCategory)[keyof typeof RootCategory]
|
||||
115
src/components/toast/SnackbarToast.stories.ts
Normal file
115
src/components/toast/SnackbarToast.stories.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
|
||||
import SnackbarToastProvider from './SnackbarToastProvider.vue'
|
||||
|
||||
const meta: Meta<typeof SnackbarToastProvider> = {
|
||||
title: 'Components/Toast/SnackbarToast',
|
||||
component: SnackbarToastProvider,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="relative h-screen bg-base-background p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToastProvider, Button, Trigger },
|
||||
template: `
|
||||
<SnackbarToastProvider>
|
||||
<Trigger label="Show toast" message="Toast message" />
|
||||
</SnackbarToastProvider>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithShortcut: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToastProvider, Button, TriggerWithShortcut },
|
||||
template: `
|
||||
<SnackbarToastProvider>
|
||||
<TriggerWithShortcut />
|
||||
</SnackbarToastProvider>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithUndoAction: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToastProvider, Button, TriggerWithUndo },
|
||||
template: `
|
||||
<SnackbarToastProvider>
|
||||
<TriggerWithUndo />
|
||||
</SnackbarToastProvider>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Persistent: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToastProvider, Button, TriggerPersistent },
|
||||
template: `
|
||||
<SnackbarToastProvider>
|
||||
<TriggerPersistent />
|
||||
</SnackbarToastProvider>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
const Trigger = {
|
||||
components: { Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
return { trigger: () => toast.show('Toast message') }
|
||||
},
|
||||
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
|
||||
}
|
||||
|
||||
const TriggerWithShortcut = {
|
||||
components: { Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
return {
|
||||
trigger: () => toast.show('Links hidden', { shortcut: 'Ctrl+A' })
|
||||
}
|
||||
},
|
||||
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
|
||||
}
|
||||
|
||||
const TriggerWithUndo = {
|
||||
components: { Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
return {
|
||||
trigger: () =>
|
||||
toast.show('Subgraph unpacked', {
|
||||
actionLabel: 'Undo',
|
||||
onAction: () => toast.show('Subgraph repacked')
|
||||
})
|
||||
}
|
||||
},
|
||||
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
|
||||
}
|
||||
|
||||
const TriggerPersistent = {
|
||||
components: { Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
return {
|
||||
trigger: () =>
|
||||
toast.show('Stays open until dismissed', { duration: 60_000 })
|
||||
}
|
||||
},
|
||||
template: `<Button class="w-fit" @click="trigger">Show toast</Button>`
|
||||
}
|
||||
77
src/components/toast/SnackbarToast.vue
Normal file
77
src/components/toast/SnackbarToast.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<ToastRoot
|
||||
:duration="toast.duration ?? DEFAULT_DURATION"
|
||||
type="foreground"
|
||||
class="flex items-center gap-4 rounded-lg bg-base-foreground py-1 pr-2 pl-3 text-sm text-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)] outline-none data-[state=closed]:opacity-0 data-[state=closed]:transition-opacity data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=end]:transition-transform data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y)"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<ToastTitle class="truncate">
|
||||
{{ toast.message }}
|
||||
</ToastTitle>
|
||||
<kbd
|
||||
v-if="toast.shortcut"
|
||||
class="flex h-4 min-w-3.5 items-center justify-center rounded-sm bg-base-background/70 px-1 text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ toast.shortcut }}
|
||||
</kbd>
|
||||
<div class="flex items-center pl-2">
|
||||
<ToastAction
|
||||
v-if="hasAction"
|
||||
as-child
|
||||
:alt-text="toast.actionLabel ?? ''"
|
||||
@click.prevent="handleAction"
|
||||
>
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="md"
|
||||
class="text-sm hover:bg-base-foreground/80"
|
||||
>
|
||||
{{ toast.actionLabel }}
|
||||
</Button>
|
||||
</ToastAction>
|
||||
<ToastClose as-child :aria-label="t('g.dismiss')">
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="md"
|
||||
class="hover:bg-base-foreground/80"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</ToastClose>
|
||||
</div>
|
||||
</ToastRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ToastAction, ToastClose, ToastRoot, ToastTitle } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SnackbarToastItem } from '@/composables/useSnackbarToast'
|
||||
|
||||
const DEFAULT_DURATION = 2000
|
||||
|
||||
const { toast } = defineProps<{ toast: SnackbarToastItem }>()
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasAction = computed(() => !!toast.onAction && !toast.shortcut)
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) emit('dismiss')
|
||||
}
|
||||
|
||||
function handleAction() {
|
||||
try {
|
||||
toast.onAction?.()
|
||||
} catch (err) {
|
||||
console.error('SnackbarToast action handler threw:', err)
|
||||
} finally {
|
||||
emit('dismiss')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
165
src/components/toast/SnackbarToastProvider.test.ts
Normal file
165
src/components/toast/SnackbarToastProvider.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SnackbarToastApi } from '@/composables/useSnackbarToast'
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
|
||||
import SnackbarToastProvider from './SnackbarToastProvider.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { dismiss: 'Dismiss' } } }
|
||||
})
|
||||
|
||||
let capturedApi: SnackbarToastApi | null = null
|
||||
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
capturedApi = useSnackbarToast()
|
||||
return () => h('div', { 'data-testid': 'harness' })
|
||||
}
|
||||
})
|
||||
|
||||
function setup(): {
|
||||
user: ReturnType<typeof userEvent.setup>
|
||||
api: SnackbarToastApi
|
||||
unmount: () => void
|
||||
} {
|
||||
capturedApi = null
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(SnackbarToastProvider, {
|
||||
slots: { default: () => h(Harness) },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const api = capturedApi
|
||||
if (!api) throw new Error('Harness did not capture api')
|
||||
return { user, api, unmount }
|
||||
}
|
||||
|
||||
describe('SnackbarToastProvider', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
// happy-dom doesn't implement these; reka-ui ToastClose/ToastAction call them
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false
|
||||
Element.prototype.releasePointerCapture = () => {}
|
||||
Element.prototype.setPointerCapture = () => {}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
capturedApi = null
|
||||
})
|
||||
|
||||
it('renders no toast initially', () => {
|
||||
setup()
|
||||
expect(screen.getByTestId('harness')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('status')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders a toast after show()', async () => {
|
||||
const { api } = setup()
|
||||
api.show('Hello world')
|
||||
await nextTick()
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces an existing toast on rapid show() (singleton)', async () => {
|
||||
const { api } = setup()
|
||||
api.show('first')
|
||||
api.show('second')
|
||||
await nextTick()
|
||||
expect(screen.queryByText('first')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a shortcut badge when shortcut is provided', async () => {
|
||||
const { api } = setup()
|
||||
api.show('Links hidden', { shortcut: 'Ctrl+A' })
|
||||
await nextTick()
|
||||
const badge = screen.getByText('Ctrl+A')
|
||||
expect(badge).toBeInTheDocument()
|
||||
// when shortcut is set, the action button must NOT render
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an action button when actionLabel is provided without shortcut', async () => {
|
||||
const { api } = setup()
|
||||
const onAction = vi.fn()
|
||||
api.show('Subgraph unpacked', { actionLabel: 'Undo', onAction })
|
||||
await nextTick()
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render action button when shortcut is also set', async () => {
|
||||
const { api } = setup()
|
||||
api.show('msg', {
|
||||
shortcut: 'Ctrl+A',
|
||||
actionLabel: 'Undo',
|
||||
onAction: vi.fn()
|
||||
})
|
||||
await nextTick()
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull()
|
||||
})
|
||||
|
||||
it('action click invokes the callback and dismisses the toast', async () => {
|
||||
const { user, api } = setup()
|
||||
const onAction = vi.fn()
|
||||
api.show('msg', { actionLabel: 'Undo', onAction })
|
||||
await nextTick()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
await nextTick()
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByText('msg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the toast even when the action callback throws', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { user, api } = setup()
|
||||
const onAction = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
api.show('msg', { actionLabel: 'Undo', onAction })
|
||||
await nextTick()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
await nextTick()
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByText('msg')).not.toBeInTheDocument()
|
||||
expect(errSpy).toHaveBeenCalled()
|
||||
errSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('dismiss(id) removes the targeted toast', async () => {
|
||||
const { api } = setup()
|
||||
const id = api.show('first')
|
||||
await nextTick()
|
||||
expect(screen.getByText('first')).toBeInTheDocument()
|
||||
api.dismiss(id)
|
||||
await nextTick()
|
||||
expect(screen.queryByText('first')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismiss(id) for an unknown id is a no-op', async () => {
|
||||
const { api } = setup()
|
||||
api.show('first')
|
||||
await nextTick()
|
||||
api.dismiss('non-existent')
|
||||
await nextTick()
|
||||
expect(screen.getByText('first')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('show() returns a unique id per call', () => {
|
||||
const { api } = setup()
|
||||
const a = api.show('a')
|
||||
const b = api.show('b')
|
||||
expect(a).not.toEqual(b)
|
||||
})
|
||||
})
|
||||
50
src/components/toast/SnackbarToastProvider.vue
Normal file
50
src/components/toast/SnackbarToastProvider.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<ToastProvider swipe-direction="down" :duration="DEFAULT_DURATION">
|
||||
<slot />
|
||||
<SnackbarToast
|
||||
v-for="item in toasts"
|
||||
:key="item.id"
|
||||
:toast="item"
|
||||
@dismiss="dismiss(item.id)"
|
||||
/>
|
||||
<ToastViewport
|
||||
class="fixed bottom-16 left-1/2 z-1000 m-0 flex -translate-x-1/2 list-none flex-col items-center gap-2 p-0 outline-none"
|
||||
/>
|
||||
</ToastProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ToastProvider, ToastViewport } from 'reka-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ShowSnackbarOptions,
|
||||
SnackbarToastApi,
|
||||
SnackbarToastItem
|
||||
} from '@/composables/useSnackbarToast'
|
||||
import { SnackbarToastKey } from '@/composables/useSnackbarToast'
|
||||
|
||||
import SnackbarToast from './SnackbarToast.vue'
|
||||
|
||||
const DEFAULT_DURATION = 2000
|
||||
|
||||
const toasts = ref<SnackbarToastItem[]>([])
|
||||
|
||||
function createId(): string {
|
||||
return `snackbar-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function show(message: string, options: ShowSnackbarOptions = {}): string {
|
||||
const item: SnackbarToastItem = { id: createId(), message, ...options }
|
||||
toasts.value = [item]
|
||||
return item.id
|
||||
}
|
||||
|
||||
function dismiss(id: string): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
const api: SnackbarToastApi = { show, dismiss }
|
||||
provide(SnackbarToastKey, api)
|
||||
defineExpose(api)
|
||||
</script>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex h-full shrink-0 items-center gap-1 empty:hidden">
|
||||
<div
|
||||
data-testid="action-bar-buttons"
|
||||
class="flex h-full shrink-0 items-center gap-1 empty:hidden"
|
||||
>
|
||||
<Button
|
||||
v-for="(button, index) in actionBarButtonStore.buttons"
|
||||
:key="index"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="!isLoggedIn"
|
||||
data-testid="login-button"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="cn('group rounded-full p-0 text-base-foreground', className)"
|
||||
@@ -21,9 +22,10 @@
|
||||
@mouseout="hidePopover"
|
||||
@mouseover="cancelHidePopover"
|
||||
>
|
||||
<div>
|
||||
<div data-testid="login-button-popover">
|
||||
<div class="mb-1">{{ t('auth.loginButton.tooltipHelp') }}</div>
|
||||
<a
|
||||
data-testid="login-button-popover-learn-more"
|
||||
:href="apiNodesOverviewUrl"
|
||||
target="_blank"
|
||||
class="text-neutral-500 hover:text-primary"
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/// <reference types="@webgpu/types" />
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { parseToRgb } from '@/utils/colorUtil'
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import {
|
||||
Tools,
|
||||
BrushShape,
|
||||
CompositionOperation
|
||||
} from '@/extensions/core/maskeditor/types'
|
||||
import type { Brush, Point } from '@/extensions/core/maskeditor/types'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
import { resampleSegment } from './splineUtils'
|
||||
@@ -24,45 +22,14 @@ import {
|
||||
drawMaskShape
|
||||
} from './brushDrawingUtils'
|
||||
import type { DirtyRect } from './brushDrawingUtils'
|
||||
|
||||
/**
|
||||
* Saves the brush settings to local storage with a debounce.
|
||||
* @param key - The storage key.
|
||||
* @param brush - The brush settings object.
|
||||
*/
|
||||
const saveBrushToCache = debounce(function (key: string, brush: Brush): void {
|
||||
try {
|
||||
const brushString = JSON.stringify(brush)
|
||||
setStorageValue(key, brushString)
|
||||
} catch (error) {
|
||||
console.error('Failed to save brush to cache:', error)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
/**
|
||||
* Loads brush settings from local storage.
|
||||
* @param key - The storage key.
|
||||
* @returns The brush settings object or null if not found.
|
||||
*/
|
||||
function loadBrushFromCache(key: string): Brush | null {
|
||||
try {
|
||||
const brushString = getStorageValue(key)
|
||||
if (brushString) {
|
||||
return JSON.parse(brushString) as Brush
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load brush from cache:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
import { useBrushPersistence } from './useBrushPersistence'
|
||||
|
||||
export function useBrushDrawing(initialSettings?: {
|
||||
useDominantAxis?: boolean
|
||||
brushAdjustmentSpeed?: number
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
const persistence = useBrushPersistence()
|
||||
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
@@ -100,14 +67,7 @@ export function useBrushDrawing(initialSettings?: {
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
|
||||
if (cachedBrushSettings) {
|
||||
store.setBrushSize(cachedBrushSettings.size)
|
||||
store.setBrushOpacity(cachedBrushSettings.opacity)
|
||||
store.setBrushHardness(cachedBrushSettings.hardness)
|
||||
store.brushSettings.type = cachedBrushSettings.type
|
||||
store.setBrushStepSize(cachedBrushSettings.stepSize ?? 5)
|
||||
}
|
||||
persistence.loadAndApply()
|
||||
|
||||
// Handle external clear events
|
||||
watch(
|
||||
@@ -865,13 +825,6 @@ export function useBrushDrawing(initialSettings?: {
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current brush settings to cache.
|
||||
*/
|
||||
function saveBrushSettings(): void {
|
||||
saveBrushToCache('maskeditor_brush_settings', store.brushSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back the GPU textures to CPU ImageDatas.
|
||||
* @returns Object containing mask and rgb ImageDatas.
|
||||
@@ -1272,7 +1225,7 @@ export function useBrushDrawing(initialSettings?: {
|
||||
drawEnd,
|
||||
startBrushAdjustment,
|
||||
handleBrushAdjustment,
|
||||
saveBrushSettings,
|
||||
saveBrushSettings: persistence.save,
|
||||
destroy,
|
||||
initGPUResources,
|
||||
initPreviewCanvas,
|
||||
|
||||
101
src/composables/maskeditor/useBrushPersistence.test.ts
Normal file
101
src/composables/maskeditor/useBrushPersistence.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('es-toolkit/compat', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
debounce: vi.fn((fn: (...args: unknown[]) => void) => {
|
||||
const immediate = (...args: unknown[]) => fn(...args)
|
||||
immediate.cancel = vi.fn()
|
||||
return immediate
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/utils', () => ({
|
||||
getStorageValue: vi.fn((key: string) => localStorage.getItem(key)),
|
||||
setStorageValue: vi.fn((key: string, value: string) => {
|
||||
localStorage.setItem(key, value)
|
||||
})
|
||||
}))
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useBrushPersistence } from './useBrushPersistence'
|
||||
|
||||
const STORAGE_KEY = 'maskeditor_brush_settings'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
localStorage.clear()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('loadAndApply', () => {
|
||||
it('does not mutate the store when localStorage is empty', () => {
|
||||
const store = useMaskEditorStore()
|
||||
const sizeBefore = store.brushSettings.size
|
||||
const { loadAndApply } = useBrushPersistence()
|
||||
loadAndApply()
|
||||
expect(store.brushSettings.size).toBe(sizeBefore)
|
||||
})
|
||||
|
||||
it('restores all brush properties from a previous save', () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
size: 42,
|
||||
opacity: 0.7,
|
||||
hardness: 0.3,
|
||||
type: 'arc',
|
||||
stepSize: 10
|
||||
})
|
||||
)
|
||||
const store = useMaskEditorStore()
|
||||
const { loadAndApply } = useBrushPersistence()
|
||||
loadAndApply()
|
||||
expect(store.brushSettings.size).toBe(42)
|
||||
expect(store.brushSettings.opacity).toBe(0.7)
|
||||
expect(store.brushSettings.hardness).toBe(0.3)
|
||||
expect(store.brushSettings.stepSize).toBe(10)
|
||||
})
|
||||
|
||||
it('falls back to stepSize=5 when the field is missing from stored data', () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ size: 20, opacity: 0.8, hardness: 0.5, type: 'arc' })
|
||||
)
|
||||
const store = useMaskEditorStore()
|
||||
const { loadAndApply } = useBrushPersistence()
|
||||
loadAndApply()
|
||||
expect(store.brushSettings.stepSize).toBe(5)
|
||||
})
|
||||
|
||||
it('does not throw on corrupted localStorage data', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-valid-json')
|
||||
const { loadAndApply } = useBrushPersistence()
|
||||
expect(() => loadAndApply()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('save', () => {
|
||||
it('writes current brush settings to localStorage', () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.size = 99
|
||||
const { save } = useBrushPersistence()
|
||||
save()
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
|
||||
expect(saved.size).toBe(99)
|
||||
})
|
||||
|
||||
it('captures settings at call time so a subsequent store reset does not overwrite the save', () => {
|
||||
const store = useMaskEditorStore()
|
||||
store.brushSettings.size = 77
|
||||
const { save } = useBrushPersistence()
|
||||
save()
|
||||
store.brushSettings.size = 10
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
|
||||
expect(saved.size).toBe(77)
|
||||
})
|
||||
})
|
||||
48
src/composables/maskeditor/useBrushPersistence.ts
Normal file
48
src/composables/maskeditor/useBrushPersistence.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import type { Brush } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const STORAGE_KEY = 'maskeditor_brush_settings'
|
||||
|
||||
function loadBrushFromStorage(): Brush | null {
|
||||
try {
|
||||
const brushString = getStorageValue(STORAGE_KEY)
|
||||
if (brushString) {
|
||||
return JSON.parse(brushString) as Brush
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to load brush from cache:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedWrite = debounce((serialized: string): void => {
|
||||
try {
|
||||
setStorageValue(STORAGE_KEY, serialized)
|
||||
} catch (error) {
|
||||
console.error('Failed to save brush to cache:', error)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
export function useBrushPersistence() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
function save(): void {
|
||||
debouncedWrite(JSON.stringify(store.brushSettings))
|
||||
}
|
||||
|
||||
function loadAndApply(): void {
|
||||
const cached = loadBrushFromStorage()
|
||||
if (!cached) return
|
||||
store.setBrushSize(cached.size)
|
||||
store.setBrushOpacity(cached.opacity)
|
||||
store.setBrushHardness(cached.hardness)
|
||||
store.brushSettings.type = cached.type
|
||||
store.setBrushStepSize(cached.stepSize ?? 5)
|
||||
}
|
||||
|
||||
return { loadAndApply, save }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useNodePricing
|
||||
} from '@/composables/node/useNodePricing'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
@@ -577,6 +578,81 @@ describe('useNodePricing', () => {
|
||||
const config = getNodePricingConfig(node)
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not leak the compiled JSONata expression', () => {
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestStripCompiledNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
const config = getNodePricingConfig(node)
|
||||
expect(config).toBeDefined()
|
||||
// _compiled is the runtime JSONata instance and must not be exposed to
|
||||
// tooling/debug consumers.
|
||||
expect(config).not.toHaveProperty('_compiled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactive revision', () => {
|
||||
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
|
||||
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestRevisionNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
const before = pricingRevision.value
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(pricingRevision.value).toBeGreaterThan(before)
|
||||
})
|
||||
|
||||
it('bumps the per-node revision ref after async evaluation resolves in VueNodes mode', async () => {
|
||||
const { getNodeDisplayPrice, getNodeRevisionRef, pricingRevision } =
|
||||
useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestVueNodeRevision',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
LiteGraph.vueNodesMode = true
|
||||
try {
|
||||
const revBefore = getNodeRevisionRef(node.id).value
|
||||
const tickBefore = pricingRevision.value
|
||||
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// VueNodes path bumps per-node ref instead of the global tick.
|
||||
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
|
||||
expect(pricingRevision.value).toBe(tickBefore)
|
||||
} finally {
|
||||
LiteGraph.vueNodesMode = false
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the cached label on a second call with the same signature', async () => {
|
||||
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestCachedSignatureNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
// First call schedules eval; second call (after resolution) is a cache hit.
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const first = getNodeDisplayPrice(node)
|
||||
|
||||
const tickAfterFirst = pricingRevision.value
|
||||
const second = getNodeDisplayPrice(node)
|
||||
// Cache-hit path must not schedule a new evaluation, so no further tick.
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(pricingRevision.value).toBe(tickAfterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeRevisionRef', () => {
|
||||
@@ -977,6 +1053,47 @@ describe('formatPricingResult', () => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-finite numbers', () => {
|
||||
it('returns empty for type:usd when usd is a non-numeric string', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: 'not-a-number' })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty for type:usd when usd is Infinity', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: Infinity })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty for type:range_usd when min_usd or max_usd is NaN', () => {
|
||||
expect(
|
||||
formatPricingResult({ type: 'range_usd', min_usd: NaN, max_usd: 0.1 })
|
||||
).toBe('')
|
||||
expect(
|
||||
formatPricingResult({ type: 'range_usd', min_usd: 0.05, max_usd: NaN })
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty for type:list_usd when usd is empty or all values are non-finite', () => {
|
||||
expect(formatPricingResult({ type: 'list_usd', usd: [] })).toBe('')
|
||||
expect(
|
||||
formatPricingResult({ type: 'list_usd', usd: [NaN, 'x', null] })
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('drops non-finite entries from type:list_usd while keeping finite ones', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'list_usd', usd: [0.05, NaN, 0.1] },
|
||||
{ valueOnly: true }
|
||||
)
|
||||
expect(result).toBe('10.6/21.1')
|
||||
})
|
||||
|
||||
it('returns empty for legacy {usd} format when usd is non-finite', () => {
|
||||
expect(formatPricingResult({ usd: NaN })).toBe('')
|
||||
expect(formatPricingResult({ usd: 'abc' })).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -141,6 +141,15 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
loadHDRI: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -97,6 +97,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
const initializeLoad3d = async (containerRef: HTMLElement) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
@@ -785,6 +794,16 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
canUseGizmo.value = caps?.gizmoTransform ?? true
|
||||
canUseLighting.value = caps?.lighting ?? true
|
||||
canExport.value = caps?.exportable ?? true
|
||||
materialModes.value = caps?.materialModes ?? [
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
]
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
applyGizmoConfigToLoad3d()
|
||||
isFirstModelLoad = false
|
||||
@@ -925,6 +944,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
|
||||
@@ -21,7 +21,21 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
uploadFile: vi.fn()
|
||||
uploadFile: vi.fn(),
|
||||
splitFilePath: vi.fn((path: string) => {
|
||||
const parts = path.split('/')
|
||||
return [parts.slice(0, -1).join('/'), parts[parts.length - 1] ?? '']
|
||||
}),
|
||||
getResourceURL: vi.fn(
|
||||
(subfolder: string, filename: string, type: string) =>
|
||||
`api/view?type=${type}&subfolder=${encodeURIComponent(subfolder)}&filename=${filename}`
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((url: string) => `/${url}`)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -116,6 +130,15 @@ describe('useLoad3dViewer', () => {
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}),
|
||||
setGizmoEnabled: vi.fn(),
|
||||
setGizmoMode: vi.fn(),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
@@ -536,6 +559,78 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleModelDrop', () => {
|
||||
it('refreshes the capability refs after the dropped model loads, so the sidebar reflects the new model', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
|
||||
'3d/dropped.splat'
|
||||
)
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(viewer.canUseLighting.value).toBe(true)
|
||||
expect(viewer.canUseGizmo.value).toBe(true)
|
||||
expect(viewer.canExport.value).toBe(true)
|
||||
expect([...viewer.materialModes.value]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValueOnce(true)
|
||||
vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValueOnce({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
})
|
||||
|
||||
const file = new File([''], 'dropped.splat')
|
||||
await viewer.handleModelDrop(file)
|
||||
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
expect.stringContaining('dropped.splat')
|
||||
)
|
||||
expect(viewer.canUseLighting.value).toBe(false)
|
||||
expect(viewer.canExport.value).toBe(false)
|
||||
expect(viewer.isSplatModel.value).toBe(true)
|
||||
expect([...viewer.materialModes.value]).toEqual([])
|
||||
})
|
||||
|
||||
it('alerts and does not call loadModel when there is no active load3d instance', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
const file = new File([''], 'whatever.glb')
|
||||
await viewer.handleModelDrop(file)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.no3dScene'
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('alerts and skips loadModel when the file upload fails', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
vi.mocked(mockLoad3d.loadModel!).mockClear()
|
||||
|
||||
const file = new File([''], 'whatever.glb')
|
||||
await viewer.handleModelDrop(file)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.fileUploadFailed'
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up resources', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
@@ -82,6 +82,26 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const isStandaloneMode = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
const captureAdapterFlags = (source: Load3d) => {
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
const caps = source.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps.fitToViewer
|
||||
canUseGizmo.value = caps.gizmoTransform
|
||||
canUseLighting.value = caps.lighting
|
||||
canExport.value = caps.exportable
|
||||
materialModes.value = caps.materialModes
|
||||
}
|
||||
|
||||
// Animation state
|
||||
const animations = ref<AnimationItem[]>([])
|
||||
@@ -395,8 +415,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
}
|
||||
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
captureAdapterFlags(source)
|
||||
|
||||
initialState.value = {
|
||||
backgroundColor: backgroundColor.value,
|
||||
@@ -456,8 +475,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
await load3d.loadModel(modelUrl)
|
||||
currentModelUrl = modelUrl
|
||||
restoreStandaloneConfig(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
captureAdapterFlags(load3d)
|
||||
|
||||
isPreview.value = true
|
||||
|
||||
@@ -480,8 +498,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
await load3d.loadModel(modelUrl)
|
||||
currentModelUrl = modelUrl
|
||||
restoreStandaloneConfig(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
captureAdapterFlags(load3d)
|
||||
} catch (error) {
|
||||
console.error('Error loading model in standalone viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
@@ -765,6 +782,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
await load3d.loadModel(modelUrl)
|
||||
|
||||
captureAdapterFlags(load3d)
|
||||
|
||||
const modelWidget = node?.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
const options = modelWidget.options as { values?: string[] } | undefined
|
||||
@@ -812,6 +831,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
materialModes,
|
||||
|
||||
// Animation state
|
||||
animations,
|
||||
|
||||
43
src/composables/useSnackbarToast.test.ts
Normal file
43
src/composables/useSnackbarToast.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, provide } from 'vue'
|
||||
|
||||
import type { SnackbarToastApi } from './useSnackbarToast'
|
||||
import { SnackbarToastKey, useSnackbarToast } from './useSnackbarToast'
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const api = useSnackbarToast()
|
||||
return () =>
|
||||
h('div', { 'data-testid': 'consumer' }, [
|
||||
h('span', { 'data-testid': 'has-show' }, String(typeof api.show)),
|
||||
h('span', { 'data-testid': 'has-dismiss' }, String(typeof api.dismiss))
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
describe('useSnackbarToast', () => {
|
||||
it('throws when no SnackbarToastProvider is in scope', () => {
|
||||
expect(() => render(Consumer)).toThrow(/SnackbarToastProvider/)
|
||||
})
|
||||
|
||||
it('returns the injected api', () => {
|
||||
const api: SnackbarToastApi = {
|
||||
show: vi.fn(() => 'id-1'),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
const Provider = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
provide(SnackbarToastKey, api)
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
|
||||
render(Provider, {
|
||||
slots: { default: () => h(Consumer) }
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('has-show').textContent).toBe('function')
|
||||
expect(screen.getByTestId('has-dismiss').textContent).toBe('function')
|
||||
})
|
||||
})
|
||||
32
src/composables/useSnackbarToast.ts
Normal file
32
src/composables/useSnackbarToast.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
export interface ShowSnackbarOptions {
|
||||
shortcut?: string
|
||||
duration?: number
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export interface SnackbarToastItem extends ShowSnackbarOptions {
|
||||
id: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SnackbarToastApi {
|
||||
show(message: string, options?: ShowSnackbarOptions): string
|
||||
dismiss(id: string): void
|
||||
}
|
||||
|
||||
export const SnackbarToastKey: InjectionKey<SnackbarToastApi> =
|
||||
Symbol('SnackbarToastApi')
|
||||
|
||||
export function useSnackbarToast(): SnackbarToastApi {
|
||||
const api = inject(SnackbarToastKey, null)
|
||||
if (!api) {
|
||||
throw new Error(
|
||||
'useSnackbarToast() must be called within <SnackbarToastProvider>.'
|
||||
)
|
||||
}
|
||||
return api
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { WidgetItem } from './promotionPolicy'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget,
|
||||
isRecommendedWidget
|
||||
} from './promotionPolicy'
|
||||
|
||||
function widget(
|
||||
overrides: Partial<
|
||||
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
|
||||
>
|
||||
): IBaseWidget {
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
function widgetItem(
|
||||
nodeType: string,
|
||||
widgetName: string,
|
||||
overrides: Partial<IBaseWidget> = {}
|
||||
): WidgetItem {
|
||||
const node = { title: nodeType, id: 1, type: nodeType }
|
||||
const w = fromPartial<IBaseWidget>({
|
||||
name: widgetName,
|
||||
computedDisabled: false,
|
||||
...overrides
|
||||
})
|
||||
return [node, w]
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
it('returns true for $$-prefixed widget names', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
|
||||
).toBe(true)
|
||||
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "preview"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: false, type: 'preview' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({
|
||||
name: 'videopreview',
|
||||
type: 'preview',
|
||||
options: { serialize: false }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "video"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'vid', serialize: false, type: 'video' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "audioUI"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'audio', serialize: false, type: 'audioUI' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for type "preview" when serialize is not false', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: true, type: 'preview' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular widgets', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for serialize:false with unknown type', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'text', serialize: false, type: 'customtext' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPromotableWidgets', () => {
|
||||
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
|
||||
const node = new LGraphNode('PreviewImage')
|
||||
node.type = 'PreviewImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for SaveImage nodes', () => {
|
||||
const node = new LGraphNode('SaveImage')
|
||||
node.type = 'SaveImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
|
||||
const node = new LGraphNode('GLSLShader')
|
||||
node.type = 'GLSLShader'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for non-image nodes', () => {
|
||||
const node = new LGraphNode('TextNode')
|
||||
node.addOutput('TEXT', 'STRING')
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
|
||||
const node = new LGraphNode('ImageInvert')
|
||||
node.type = 'ImageInvert'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRecommendedWidget', () => {
|
||||
it('returns true for widgets on recommended node types', () => {
|
||||
expect(isRecommendedWidget(widgetItem('CLIPTextEncode', 'text'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('LoadImage', 'image'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('SaveImage', 'filename'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('PreviewImage', 'anything'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns true for seed widgets regardless of node type', () => {
|
||||
expect(isRecommendedWidget(widgetItem('KSampler', 'seed'))).toBe(true)
|
||||
expect(isRecommendedWidget(widgetItem('KSamplerAdvanced', 'seed'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false for non-recommended node and widget combinations', () => {
|
||||
expect(isRecommendedWidget(widgetItem('KSampler', 'steps'))).toBe(false)
|
||||
expect(isRecommendedWidget(widgetItem('VAEDecode', 'samples'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when widget is computedDisabled', () => {
|
||||
expect(
|
||||
isRecommendedWidget(
|
||||
widgetItem('CLIPTextEncode', 'text', { computedDisabled: true })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isRecommendedWidget(
|
||||
widgetItem('KSampler', 'seed', { computedDisabled: true })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
|
||||
export type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
/**
|
||||
* Returns true for pseudo-widgets that display media previews and should
|
||||
* be auto-promoted when their node is inside a subgraph.
|
||||
* Matches the core `$$` convention as well as custom-node patterns
|
||||
* (e.g. VHS `videopreview` with type `"preview"`).
|
||||
*/
|
||||
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
|
||||
if (widget.name.startsWith('$$')) return true
|
||||
// Custom nodes may set serialize on the widget or in options
|
||||
if (widget.serialize !== false && widget.options?.serialize !== false)
|
||||
return false
|
||||
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
const recommendedNodes = [
|
||||
'CLIPTextEncode',
|
||||
'LoadImage',
|
||||
'SaveImage',
|
||||
'PreviewImage'
|
||||
]
|
||||
const recommendedWidgetNames = ['seed']
|
||||
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
||||
return (
|
||||
!widget.computedDisabled &&
|
||||
(recommendedNodes.includes(node.type) ||
|
||||
recommendedWidgetNames.includes(widget.name))
|
||||
)
|
||||
}
|
||||
|
||||
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
|
||||
return {
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'IMAGE_PREVIEW',
|
||||
options: { serialize: false },
|
||||
serialize: false,
|
||||
y: 0,
|
||||
computedDisabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
const widgets = [...(node.widgets ?? [])]
|
||||
|
||||
const hasCanvasPreviewWidget = widgets.some(
|
||||
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
const supportsVirtualPreview = supportsVirtualCanvasImagePreview(node)
|
||||
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
|
||||
widgets.push(createVirtualCanvasImagePreviewWidget())
|
||||
}
|
||||
|
||||
return widgets
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user