mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-10 22:40:00 +00:00
Compare commits
2 Commits
test/botto
...
test/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c427749c9a | ||
|
|
0353524e6f |
@@ -40,6 +40,39 @@ browser_tests/
|
||||
- **`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.
|
||||
|
||||
## Page Object Locator Style
|
||||
|
||||
Define UI element locators as `public readonly` properties assigned in the constructor — not as getter methods. Getters that simply return a locator add unnecessary indirection and hide the object shape from IDE auto-complete.
|
||||
|
||||
```typescript
|
||||
// ✅ Correct — public readonly, assigned in constructor
|
||||
export class MyDialog extends BaseDialog {
|
||||
public readonly submitButton: Locator
|
||||
public readonly cancelButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.submitButton = this.root.getByRole('button', { name: 'Submit' })
|
||||
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Avoid — getter-based locators
|
||||
export class MyDialog extends BaseDialog {
|
||||
get submitButton() {
|
||||
return this.root.getByRole('button', { name: 'Submit' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Keep as getters only when:**
|
||||
|
||||
- Lazy initialization is needed (`this._tab ??= new Tab(this.page)`)
|
||||
- The value is computed from runtime state (e.g. `get id() { return this.userIds[index] }`)
|
||||
- It's a private convenience accessor (e.g. `private get page() { return this.comfyPage.page }`)
|
||||
|
||||
When a class has cached locator properties, prefer reusing them in methods rather than rebuilding locators from scratch.
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
@@ -26,11 +26,10 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
static defaultSteps = 5
|
||||
static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps }
|
||||
|
||||
constructor(readonly comfyPage: ComfyPage) {}
|
||||
readonly mouse: Mouse
|
||||
|
||||
/** The normal Playwright {@link Mouse} property from {@link ComfyPage.page}. */
|
||||
get mouse() {
|
||||
return this.comfyPage.page.mouse
|
||||
constructor(readonly comfyPage: ComfyPage) {
|
||||
this.mouse = comfyPage.page.mouse
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
|
||||
@@ -73,15 +73,13 @@ class ComfyMenu {
|
||||
public readonly sideToolbar: Locator
|
||||
public readonly propertiesPanel: ComfyPropertiesPanel
|
||||
public readonly modeToggleButton: Locator
|
||||
public readonly buttons: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar)
|
||||
this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle)
|
||||
this.propertiesPanel = new ComfyPropertiesPanel(page)
|
||||
}
|
||||
|
||||
get buttons() {
|
||||
return this.sideToolbar.locator('.side-bar-button')
|
||||
this.buttons = this.sideToolbar.locator('.side-bar-button')
|
||||
}
|
||||
|
||||
get modelLibraryTab() {
|
||||
@@ -183,6 +181,7 @@ export class ComfyPage {
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
public readonly visibleToasts: Locator
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -225,6 +224,7 @@ export class ComfyPage {
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.visibleToasts = this.toast.visibleToasts
|
||||
this.dragDrop = new DragDropHelper(page)
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
@@ -237,10 +237,6 @@ export class ComfyPage {
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
return this.toast.visibleToasts
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export class UserSelectPage {
|
||||
public readonly selectionUrl: string
|
||||
public readonly container: Locator
|
||||
public readonly newUserInput: Locator
|
||||
public readonly existingUserSelect: Locator
|
||||
public readonly nextButton: Locator
|
||||
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
public readonly page: Page
|
||||
) {}
|
||||
|
||||
get selectionUrl() {
|
||||
return this.url + '/user-select'
|
||||
}
|
||||
|
||||
get container() {
|
||||
return this.page.locator('#comfy-user-selection')
|
||||
}
|
||||
|
||||
get newUserInput() {
|
||||
return this.container.locator('#new-user-input')
|
||||
}
|
||||
|
||||
get existingUserSelect() {
|
||||
return this.container.locator('#existing-user-select')
|
||||
}
|
||||
|
||||
get nextButton() {
|
||||
return this.container.getByText('Next')
|
||||
) {
|
||||
this.selectionUrl = url + '/user-select'
|
||||
this.container = page.locator('#comfy-user-selection')
|
||||
this.newUserInput = this.container.locator('#new-user-input')
|
||||
this.existingUserSelect = this.container.locator('#existing-user-select')
|
||||
this.nextButton = this.container.getByText('Next')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,20 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Get locator for all Vue node components in the DOM
|
||||
*/
|
||||
get nodes(): Locator {
|
||||
return this.page.locator('[data-node-id]')
|
||||
public readonly nodes: Locator
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
public readonly selectedNodes: Locator
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.nodes = page.locator('[data-node-id]')
|
||||
this.selectedNodes = page.locator(
|
||||
'[data-node-id].outline-node-component-outline'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,13 +30,6 @@ export class VueNodeHelpers {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
get selectedNodes(): Locator {
|
||||
return this.page.locator('[data-node-id].outline-node-component-outline')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
|
||||
@@ -3,13 +3,11 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
readonly header: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.root
|
||||
this.header = this.root
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Add node filter condition' })
|
||||
}
|
||||
@@ -41,6 +39,8 @@ export class ComfyNodeSearchFilterSelectionPanel {
|
||||
export class ComfyNodeSearchBox {
|
||||
public readonly input: Locator
|
||||
public readonly dropdown: Locator
|
||||
public readonly filterButton: Locator
|
||||
public readonly filterChips: Locator
|
||||
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
@@ -50,13 +50,15 @@ export class ComfyNodeSearchBox {
|
||||
this.dropdown = page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-list'
|
||||
)
|
||||
this.filterButton = page.locator(
|
||||
'.comfy-vue-node-search-container .filter-button'
|
||||
)
|
||||
this.filterChips = page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||
)
|
||||
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
||||
}
|
||||
|
||||
get filterButton() {
|
||||
return this.page.locator('.comfy-vue-node-search-container .filter-button')
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(
|
||||
nodeName: string,
|
||||
options?: { suggestionIndex?: number; exact?: boolean }
|
||||
@@ -78,12 +80,6 @@ export class ComfyNodeSearchBox {
|
||||
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
||||
}
|
||||
|
||||
get filterChips() {
|
||||
return this.page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||
)
|
||||
}
|
||||
|
||||
async removeFilter(index: number) {
|
||||
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
||||
}
|
||||
|
||||
@@ -2,18 +2,14 @@ import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
|
||||
get primeVueMenu() {
|
||||
return this.page.locator('.p-contextmenu, .p-menu')
|
||||
}
|
||||
|
||||
get litegraphMenu() {
|
||||
return this.page.locator('.litemenu')
|
||||
}
|
||||
|
||||
get menuItems() {
|
||||
return this.page.locator('.p-menuitem, .litemenu-entry')
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class SettingDialog extends BaseDialog {
|
||||
public readonly searchBox: Locator
|
||||
public readonly categories: Locator
|
||||
public readonly contentArea: Locator
|
||||
|
||||
constructor(
|
||||
page: Page,
|
||||
public readonly comfyPage: ComfyPage
|
||||
) {
|
||||
super(page, TestIds.dialogs.settings)
|
||||
this.searchBox = this.root.getByPlaceholder(/Search/)
|
||||
this.categories = this.root.locator('nav').getByRole('button')
|
||||
this.contentArea = this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -36,22 +43,10 @@ export class SettingDialog extends BaseDialog {
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
get searchBox() {
|
||||
return this.root.getByPlaceholder(/Search/)
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.root.locator('nav').getByRole('button')
|
||||
}
|
||||
|
||||
category(name: string) {
|
||||
return this.root.locator('nav').getByRole('button', { name })
|
||||
}
|
||||
|
||||
get contentArea() {
|
||||
return this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
|
||||
@@ -5,18 +5,16 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly tabId: string
|
||||
) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
) {
|
||||
this.tabButton = page.locator(`.${tabId}-tab-button`)
|
||||
this.selectedTabButton = page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,28 +33,19 @@ class SidebarTab {
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
public readonly nodeLibrarySearchBoxInput: Locator
|
||||
public readonly nodeLibraryTree: Locator
|
||||
public readonly nodePreview: Locator
|
||||
public readonly tabContainer: Locator
|
||||
public readonly newFolderButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get nodeLibrarySearchBoxInput() {
|
||||
return this.page.getByPlaceholder('Search Nodes...')
|
||||
}
|
||||
|
||||
get nodeLibraryTree() {
|
||||
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
|
||||
}
|
||||
|
||||
get nodePreview() {
|
||||
return this.page.locator('.node-lib-node-preview')
|
||||
}
|
||||
|
||||
get tabContainer() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
get newFolderButton() {
|
||||
return this.tabContainer.locator('.new-folder-button')
|
||||
this.nodeLibrarySearchBoxInput = page.getByPlaceholder('Search Nodes...')
|
||||
this.nodeLibraryTree = page.getByTestId(TestIds.sidebar.nodeLibrary)
|
||||
this.nodePreview = page.locator('.node-lib-node-preview')
|
||||
this.tabContainer = page.locator('.sidebar-content-container')
|
||||
this.newFolderButton = this.tabContainer.locator('.new-folder-button')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
@@ -101,34 +90,25 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly sidebarContent: Locator
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search...')
|
||||
}
|
||||
|
||||
get sidebarContent() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
this.searchInput = page.getByPlaceholder('Search...')
|
||||
this.sidebarContent = page.locator('.sidebar-content-container')
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
return this.sidebarContent.getByRole('tab', { name, exact: true })
|
||||
}
|
||||
|
||||
get allTab() {
|
||||
return this.getTab('All')
|
||||
}
|
||||
|
||||
get blueprintsTab() {
|
||||
return this.getTab('Blueprints')
|
||||
}
|
||||
|
||||
get sortButton() {
|
||||
return this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.sidebarContent
|
||||
.getByRole('treeitem', { name: folderName })
|
||||
@@ -154,12 +134,15 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly activeWorkflowLabel: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.root = page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.activeWorkflowLabel = this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
@@ -168,12 +151,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
get activeWorkflowLabel(): Locator {
|
||||
return this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getActiveWorkflowName() {
|
||||
return await this.activeWorkflowLabel.innerText()
|
||||
}
|
||||
@@ -228,36 +205,27 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly modelTree: Locator
|
||||
public readonly refreshButton: Locator
|
||||
public readonly loadAllFoldersButton: Locator
|
||||
public readonly folderNodes: Locator
|
||||
public readonly leafNodes: Locator
|
||||
public readonly modelPreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'model-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Models...')
|
||||
}
|
||||
|
||||
get modelTree() {
|
||||
return this.page.locator('.model-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get refreshButton() {
|
||||
return this.page.getByRole('button', { name: 'Refresh' })
|
||||
}
|
||||
|
||||
get loadAllFoldersButton() {
|
||||
return this.page.getByRole('button', { name: 'Load All Folders' })
|
||||
}
|
||||
|
||||
get folderNodes() {
|
||||
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
||||
}
|
||||
|
||||
get leafNodes() {
|
||||
return this.modelTree.locator('.p-tree-node-leaf')
|
||||
}
|
||||
|
||||
get modelPreview() {
|
||||
return this.page.locator('.model-lib-model-preview')
|
||||
this.searchInput = page.getByPlaceholder('Search Models...')
|
||||
this.modelTree = page.locator('.model-lib-tree-explorer')
|
||||
this.refreshButton = page.getByRole('button', { name: 'Refresh' })
|
||||
this.loadAllFoldersButton = page.getByRole('button', {
|
||||
name: 'Load All Folders'
|
||||
})
|
||||
this.folderNodes = this.modelTree.locator(
|
||||
'.p-tree-node:not(.p-tree-node-leaf)'
|
||||
)
|
||||
this.leafNodes = this.modelTree.locator('.p-tree-node-leaf')
|
||||
this.modelPreview = page.locator('.model-lib-model-preview')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
@@ -281,137 +249,95 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
public readonly generatedTab: Locator
|
||||
public readonly importedTab: Locator
|
||||
|
||||
// --- Empty state ---
|
||||
public readonly emptyStateMessage: Locator
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
public readonly selectedCards: Locator
|
||||
|
||||
// --- List view items ---
|
||||
public readonly listViewItems: Locator
|
||||
|
||||
// --- Selection footer ---
|
||||
public readonly selectionFooter: Locator
|
||||
public readonly selectionCountButton: Locator
|
||||
public readonly deselectAllButton: Locator
|
||||
public readonly deleteSelectedButton: Locator
|
||||
public readonly downloadSelectedButton: Locator
|
||||
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
|
||||
// --- Loading ---
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
|
||||
this.importedTab = page.getByRole('tab', { name: 'Imported' })
|
||||
this.emptyStateMessage = page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.assetCards = page.locator('[role="button"][data-selected]')
|
||||
this.selectedCards = page.locator('[data-selected="true"]')
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
|
||||
@@ -10,6 +10,17 @@ export class SignInDialog extends BaseDialog {
|
||||
readonly apiKeyButton: Locator
|
||||
readonly termsLink: Locator
|
||||
readonly privacyLink: Locator
|
||||
readonly heading: Locator
|
||||
readonly signUpLink: Locator
|
||||
readonly signInLink: Locator
|
||||
readonly signUpEmailInput: Locator
|
||||
readonly signUpPasswordInput: Locator
|
||||
readonly signUpConfirmPasswordInput: Locator
|
||||
readonly signUpButton: Locator
|
||||
readonly apiKeyHeading: Locator
|
||||
readonly apiKeyInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly dividerText: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
@@ -22,6 +33,22 @@ export class SignInDialog extends BaseDialog {
|
||||
})
|
||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||
this.heading = this.root.getByRole('heading').first()
|
||||
this.signUpLink = this.root.getByText('Sign up', { exact: true })
|
||||
this.signInLink = this.root.getByText('Sign in', { exact: true })
|
||||
this.signUpEmailInput = this.root.locator('#comfy-org-sign-up-email')
|
||||
this.signUpPasswordInput = this.root.locator('#comfy-org-sign-up-password')
|
||||
this.signUpConfirmPasswordInput = this.root.locator(
|
||||
'#comfy-org-sign-up-confirm-password'
|
||||
)
|
||||
this.signUpButton = this.root.getByRole('button', {
|
||||
name: 'Sign up',
|
||||
exact: true
|
||||
})
|
||||
this.apiKeyHeading = this.root.getByRole('heading', { name: 'API Key' })
|
||||
this.apiKeyInput = this.root.locator('#comfy-org-api-key')
|
||||
this.backButton = this.root.getByRole('button', { name: 'Back' })
|
||||
this.dividerText = this.root.getByText('Or continue with')
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -30,48 +57,4 @@ export class SignInDialog extends BaseDialog {
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.root.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get signUpLink() {
|
||||
return this.root.getByText('Sign up', { exact: true })
|
||||
}
|
||||
|
||||
get signInLink() {
|
||||
return this.root.getByText('Sign in', { exact: true })
|
||||
}
|
||||
|
||||
get signUpEmailInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-email')
|
||||
}
|
||||
|
||||
get signUpPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-password')
|
||||
}
|
||||
|
||||
get signUpConfirmPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
||||
}
|
||||
|
||||
get signUpButton() {
|
||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
||||
}
|
||||
|
||||
get apiKeyHeading() {
|
||||
return this.root.getByRole('heading', { name: 'API Key' })
|
||||
}
|
||||
|
||||
get apiKeyInput() {
|
||||
return this.root.locator('#comfy-org-api-key')
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.root.getByRole('button', { name: 'Back' })
|
||||
}
|
||||
|
||||
get dividerText() {
|
||||
return this.root.getByText('Or continue with')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
private readonly menuTrigger: Locator
|
||||
readonly newWorkflowButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menuLocator = page.locator('.comfy-command-menu')
|
||||
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
||||
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
@@ -50,10 +52,6 @@ export class Topbar {
|
||||
return classes ? !classes.includes('invisible') : false
|
||||
}
|
||||
|
||||
get newWorkflowButton(): Locator {
|
||||
return this.page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
getWorkflowTab(tabName: string): Locator {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
|
||||
@@ -15,6 +15,26 @@ export class AppModeHelper {
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
@@ -22,6 +42,31 @@ export class AppModeHelper {
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.locator('[data-testid="linear-widgets"]')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -93,61 +138,6 @@ export class AppModeHelper {
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
get connectOutputPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
||||
}
|
||||
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
get outputPlaceholder(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.outputPlaceholder)
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
}
|
||||
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
get imagePickerPopover(): Locator {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
}
|
||||
|
||||
/** The Run button in the app mode footer. */
|
||||
get runButton(): Locator {
|
||||
return this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
}
|
||||
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
get welcome(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.welcome)
|
||||
}
|
||||
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
get emptyWorkflowText(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
|
||||
}
|
||||
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
get buildAppButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
}
|
||||
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
get backToWorkflowButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
|
||||
}
|
||||
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
get loadTemplateButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.loadTemplate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
|
||||
@@ -4,48 +4,32 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly nav: Locator
|
||||
public readonly exitButton: Locator
|
||||
public readonly nextButton: Locator
|
||||
public readonly backButton: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly saveGroup: Locator
|
||||
public readonly saveAsButton: Locator
|
||||
public readonly saveAsChevron: Locator
|
||||
public readonly opensAsPopover: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.nav = this.page.getByTestId(TestIds.builder.footerNav)
|
||||
this.exitButton = this.buttonByName('Exit app builder')
|
||||
this.nextButton = this.buttonByName('Next')
|
||||
this.backButton = this.buttonByName('Back')
|
||||
this.saveButton = this.page.getByTestId(TestIds.builder.saveButton)
|
||||
this.saveGroup = this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
this.saveAsButton = this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
this.saveAsChevron = this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
this.opensAsPopover = this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get nav(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
||||
}
|
||||
|
||||
get exitButton(): Locator {
|
||||
return this.buttonByName('Exit app builder')
|
||||
}
|
||||
|
||||
get nextButton(): Locator {
|
||||
return this.buttonByName('Next')
|
||||
}
|
||||
|
||||
get backButton(): Locator {
|
||||
return this.buttonByName('Back')
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveGroup(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
get saveAsChevron(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
}
|
||||
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private buttonByName(name: string): Locator {
|
||||
return this.nav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
@@ -3,73 +3,61 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
public readonly dialog: Locator
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
public readonly successDialog: Locator
|
||||
public readonly title: Locator
|
||||
public readonly radioGroup: Locator
|
||||
public readonly nameInput: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly successMessage: Locator
|
||||
public readonly viewAppButton: Locator
|
||||
public readonly closeButton: Locator
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
public readonly dismissButton: Locator
|
||||
public readonly exitBuilderButton: Locator
|
||||
public readonly overwriteDialog: Locator
|
||||
public readonly overwriteButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.dialog = this.page.locator('[aria-labelledby="builder-save"]')
|
||||
this.successDialog = this.page.locator(
|
||||
'[aria-labelledby="builder-save-success"]'
|
||||
)
|
||||
this.title = this.dialog.getByText('Save as')
|
||||
this.radioGroup = this.dialog.getByRole('radiogroup')
|
||||
this.nameInput = this.dialog.getByRole('textbox')
|
||||
this.saveButton = this.dialog.getByRole('button', { name: 'Save' })
|
||||
this.successMessage = this.successDialog.getByText('Successfully saved')
|
||||
this.viewAppButton = this.successDialog.getByRole('button', {
|
||||
name: 'View app'
|
||||
})
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
this.overwriteDialog = this.page.getByRole('dialog', {
|
||||
name: 'Overwrite existing file?'
|
||||
})
|
||||
this.overwriteButton = this.overwriteDialog.getByRole('button', {
|
||||
name: 'Overwrite'
|
||||
})
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
get dialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
||||
}
|
||||
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
get successDialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.dialog.getByText('Save as')
|
||||
}
|
||||
|
||||
get radioGroup(): Locator {
|
||||
return this.dialog.getByRole('radiogroup')
|
||||
}
|
||||
|
||||
get nameInput(): Locator {
|
||||
return this.dialog.getByRole('textbox')
|
||||
}
|
||||
|
||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||
return this.dialog.getByRole('radio', { name: viewType })
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
get successMessage(): Locator {
|
||||
return this.successDialog.getByText('Successfully saved')
|
||||
}
|
||||
|
||||
get viewAppButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
||||
}
|
||||
|
||||
get closeButton(): Locator {
|
||||
return this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
}
|
||||
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
get dismissButton(): Locator {
|
||||
return this.successDialog.locator('button.p-dialog-close-button')
|
||||
}
|
||||
|
||||
get exitBuilderButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
||||
}
|
||||
|
||||
get overwriteDialog(): Locator {
|
||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
||||
}
|
||||
|
||||
get overwriteButton(): Locator {
|
||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
||||
}
|
||||
|
||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||
await this.nameInput.fill(workflowName)
|
||||
await this.viewTypeRadio(viewType).click()
|
||||
|
||||
@@ -32,7 +32,20 @@ async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
public readonly inputItems: Locator
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
public readonly inputItemTitles: Locator
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
public readonly previewWidgetLabels: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.inputItems = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
this.inputItemTitles = this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
this.previewWidgetLabels = this.page.getByTestId(
|
||||
TestIds.builder.widgetLabel
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
@@ -43,12 +56,9 @@ export class BuilderSelectHelper {
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
return this.inputItems
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
@@ -150,38 +160,19 @@ export class BuilderSelectHelper {
|
||||
* Useful for asserting "Widget not visible" on disconnected inputs.
|
||||
*/
|
||||
getInputItemSubtitle(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
return this.inputItems
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.ioItemSubtitle)
|
||||
}
|
||||
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
get inputItems(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItem)
|
||||
}
|
||||
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
}
|
||||
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
get previewWidgetLabels(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an IoItem from one index to another in the inputs step.
|
||||
* Items are identified by their 0-based position among visible IoItems.
|
||||
*/
|
||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await dragByIndex(this.inputItems, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly toolbar: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.toolbar = this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get toolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class NodeOperationsHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
}
|
||||
|
||||
private get page() {
|
||||
return this.comfyPage.page
|
||||
@@ -155,10 +161,6 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
get promptDialogInput(): Locator {
|
||||
return this.page.locator('.p-dialog-content input[type="text"]')
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
await this.promptDialogInput.fill(value)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -2,14 +2,12 @@ import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ToastHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
public readonly visibleToasts: Locator
|
||||
public readonly toastErrors: Locator
|
||||
|
||||
get visibleToasts(): Locator {
|
||||
return this.page.locator('.p-toast-message:visible')
|
||||
}
|
||||
|
||||
get toastErrors(): Locator {
|
||||
return this.page.locator('.p-toast-message.p-toast-message-error')
|
||||
constructor(private readonly page: Page) {
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0): Promise<void> {
|
||||
|
||||
@@ -4,38 +4,26 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {}
|
||||
public readonly header: Locator
|
||||
public readonly title: Locator
|
||||
public readonly titleInput: Locator
|
||||
public readonly body: Locator
|
||||
public readonly pinIndicator: Locator
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
|
||||
get header(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-header-"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title-input"]')
|
||||
}
|
||||
|
||||
get body(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-body-"]')
|
||||
}
|
||||
|
||||
get pinIndicator(): Locator {
|
||||
return this.locator.getByTestId(TestIds.node.pinIndicator)
|
||||
}
|
||||
|
||||
get collapseButton(): Locator {
|
||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
get collapseIcon(): Locator {
|
||||
return this.collapseButton.locator('i')
|
||||
}
|
||||
|
||||
get root(): Locator {
|
||||
return this.locator
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
this.title = locator.locator('[data-testid="node-title"]')
|
||||
this.titleInput = locator.locator('[data-testid="node-title-input"]')
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.locator(
|
||||
'[data-testid="node-collapse-button"]'
|
||||
)
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
668
src/composables/useImageCrop.test.ts
Normal file
668
src/composables/useImageCrop.test.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useResizeObserver: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
const getNodeImageUrls = vi.fn()
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
getNodeImageUrls,
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import type { useImageCrop as UseImageCropFn } from './useImageCrop'
|
||||
import { useImageCrop } from './useImageCrop'
|
||||
|
||||
function createMockNode(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
getInputNode: vi.fn().mockReturnValue(null),
|
||||
getInputLink: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createPointerEvent(
|
||||
type: string,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): PointerEvent {
|
||||
const event = new PointerEvent(type, { clientX, clientY })
|
||||
Object.defineProperty(event, 'target', {
|
||||
value: {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn()
|
||||
}
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
function createOptions(modelValue?: Partial<Bounds>) {
|
||||
const bounds: Bounds = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
...modelValue
|
||||
}
|
||||
return {
|
||||
imageEl: ref<HTMLImageElement | null>(null),
|
||||
containerEl: ref<HTMLDivElement | null>(null),
|
||||
modelValue: ref(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
// Single wrapper component used to trigger onMounted lifecycle
|
||||
const Wrapper = defineComponent({
|
||||
props: { run: { type: Function, required: true } },
|
||||
setup(props) {
|
||||
props.run()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
|
||||
function mountComposable(
|
||||
options: ReturnType<typeof createOptions>,
|
||||
nodeId: NodeId = 1
|
||||
) {
|
||||
let result!: ReturnType<typeof UseImageCropFn>
|
||||
|
||||
mount(Wrapper, {
|
||||
props: { run: () => (result = useImageCrop(nodeId, options)) }
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function setup(modelValue?: Partial<Bounds>, nodeId: NodeId = 1) {
|
||||
const options = createOptions(modelValue)
|
||||
const result = mountComposable(options, nodeId)
|
||||
return { ...result, options }
|
||||
}
|
||||
|
||||
function setupWithImage(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
modelValue?: Partial<Bounds>
|
||||
) {
|
||||
const options = createOptions({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
...modelValue
|
||||
})
|
||||
|
||||
options.imageEl.value = {
|
||||
naturalWidth,
|
||||
naturalHeight
|
||||
} as HTMLImageElement
|
||||
|
||||
options.containerEl.value = {
|
||||
clientWidth: containerWidth,
|
||||
clientHeight: containerHeight,
|
||||
getBoundingClientRect: () => ({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: containerWidth,
|
||||
bottom: containerHeight,
|
||||
toJSON: () => {}
|
||||
})
|
||||
} as unknown as HTMLDivElement
|
||||
|
||||
const result = mountComposable(options)
|
||||
result.handleImageLoad()
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useImageCrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(resolveNode).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('crop computed properties', () => {
|
||||
it('reads crop dimensions from modelValue', () => {
|
||||
const { cropX, cropY, cropWidth, cropHeight } = setup({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 200,
|
||||
height: 300
|
||||
})
|
||||
|
||||
expect(cropX.value).toBe(10)
|
||||
expect(cropY.value).toBe(20)
|
||||
expect(cropWidth.value).toBe(200)
|
||||
expect(cropHeight.value).toBe(300)
|
||||
})
|
||||
|
||||
it('writes crop dimensions back to modelValue', () => {
|
||||
const { cropX, cropY, cropWidth, cropHeight, options } = setup()
|
||||
|
||||
cropX.value = 50
|
||||
cropY.value = 60
|
||||
cropWidth.value = 100
|
||||
cropHeight.value = 150
|
||||
|
||||
expect(options.modelValue.value).toMatchObject({
|
||||
x: 50,
|
||||
y: 60,
|
||||
width: 100,
|
||||
height: 150
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults cropWidth/cropHeight to 512 when modelValue is 0', () => {
|
||||
const { cropWidth, cropHeight } = setup({ width: 0, height: 0 })
|
||||
|
||||
expect(cropWidth.value).toBe(512)
|
||||
expect(cropHeight.value).toBe(512)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cropBoxStyle', () => {
|
||||
it('computes style from crop state and scale factor', () => {
|
||||
const { cropBoxStyle } = setup({
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150
|
||||
})
|
||||
|
||||
// With default scaleFactor=1 and offsets=0, border=2
|
||||
expect(cropBoxStyle.value).toMatchObject({
|
||||
left: `${100 * 1 - 2}px`,
|
||||
top: `${50 * 1 - 2}px`,
|
||||
width: `${200 * 1}px`,
|
||||
height: `${150 * 1}px`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedRatio', () => {
|
||||
it('defaults to custom when no ratio is locked', () => {
|
||||
const { selectedRatio } = setup()
|
||||
expect(selectedRatio.value).toBe('custom')
|
||||
})
|
||||
|
||||
it('sets lockedRatio when selecting a predefined ratio', () => {
|
||||
const { selectedRatio, isLockEnabled } = setup()
|
||||
|
||||
selectedRatio.value = '16:9'
|
||||
|
||||
expect(isLockEnabled.value).toBe(true)
|
||||
expect(selectedRatio.value).toBe('16:9')
|
||||
})
|
||||
|
||||
it('clears lockedRatio when selecting custom', () => {
|
||||
const { selectedRatio, isLockEnabled } = setup()
|
||||
|
||||
selectedRatio.value = '1:1'
|
||||
expect(isLockEnabled.value).toBe(true)
|
||||
|
||||
selectedRatio.value = 'custom'
|
||||
expect(isLockEnabled.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLockEnabled', () => {
|
||||
it('derives locked ratio from current crop dimensions', () => {
|
||||
const { isLockEnabled, selectedRatio } = setup({
|
||||
width: 400,
|
||||
height: 200
|
||||
})
|
||||
|
||||
isLockEnabled.value = true
|
||||
|
||||
// Should compute ratio as 400/200 = 2, not match any preset
|
||||
expect(selectedRatio.value).toBe('custom')
|
||||
expect(isLockEnabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('unlocks when set to false', () => {
|
||||
const { isLockEnabled, selectedRatio } = setup()
|
||||
|
||||
isLockEnabled.value = true
|
||||
isLockEnabled.value = false
|
||||
|
||||
expect(isLockEnabled.value).toBe(false)
|
||||
expect(selectedRatio.value).toBe('custom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLockedRatio (via selectedRatio setter)', () => {
|
||||
it('adjusts height to match 1:1 ratio when image is loaded', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 400
|
||||
})
|
||||
|
||||
result.selectedRatio.value = '1:1'
|
||||
|
||||
expect(result.cropWidth.value).toBe(200)
|
||||
expect(result.cropHeight.value).toBe(200)
|
||||
})
|
||||
|
||||
it('clamps height and adjusts width at naturalHeight boundary', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 0,
|
||||
y: 900,
|
||||
width: 200,
|
||||
height: 100
|
||||
})
|
||||
|
||||
result.selectedRatio.value = '1:1'
|
||||
|
||||
// Only 100px remain (1000 - 900), so height=100, width=100
|
||||
expect(result.cropHeight.value).toBeLessThanOrEqual(100)
|
||||
expect(result.cropWidth.value).toBe(result.cropHeight.value)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resizeHandles', () => {
|
||||
it('returns all 8 handles when ratio is unlocked', () => {
|
||||
const { resizeHandles } = setup()
|
||||
|
||||
expect(resizeHandles.value).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('returns only corner handles when ratio is locked', () => {
|
||||
const { resizeHandles, isLockEnabled } = setup()
|
||||
|
||||
isLockEnabled.value = true
|
||||
|
||||
const directions = resizeHandles.value.map((h) => h.direction)
|
||||
expect(directions).toEqual(['nw', 'ne', 'sw', 'se'])
|
||||
expect(resizeHandles.value).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleImageLoad', () => {
|
||||
it('sets isLoading to false', () => {
|
||||
const { isLoading, handleImageLoad } = setup()
|
||||
|
||||
isLoading.value = true
|
||||
handleImageLoad()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleImageError', () => {
|
||||
it('sets isLoading to false and clears imageUrl', () => {
|
||||
const { isLoading, imageUrl, handleImageError } = setup()
|
||||
|
||||
isLoading.value = true
|
||||
imageUrl.value = 'http://example.com/img.png'
|
||||
handleImageError()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragStart/Move/End', () => {
|
||||
it('does nothing when imageUrl is null', () => {
|
||||
const { handleDragStart, cropX } = setup({ x: 100 })
|
||||
|
||||
handleDragStart(createPointerEvent('pointerdown', 50, 50))
|
||||
|
||||
expect(cropX.value).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores drag move when not dragging', () => {
|
||||
const { handleDragMove, cropX } = setup({ x: 100 })
|
||||
|
||||
handleDragMove(createPointerEvent('pointermove', 200, 200))
|
||||
|
||||
expect(cropX.value).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores drag end when not dragging', () => {
|
||||
const { handleDragEnd } = setup()
|
||||
|
||||
handleDragEnd(createPointerEvent('pointerup', 200, 200))
|
||||
})
|
||||
|
||||
it('moves crop box by pointer delta', () => {
|
||||
// 1000x1000 image in 500x500 container: effectiveScale = 0.5
|
||||
// pointer delta of 50px -> natural delta of 50/0.5 = 100
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
|
||||
result.handleDragMove(createPointerEvent('pointermove', 50, 30))
|
||||
|
||||
expect(result.cropX.value).toBe(200)
|
||||
expect(result.cropY.value).toBe(160)
|
||||
|
||||
result.handleDragEnd(createPointerEvent('pointerup', 50, 30))
|
||||
})
|
||||
|
||||
it('clamps drag to image boundaries', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 700,
|
||||
y: 700,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
|
||||
// delta = 500/0.5 = 1000, so x would be 1700 but max is 800
|
||||
result.handleDragMove(createPointerEvent('pointermove', 500, 500))
|
||||
|
||||
expect(result.cropX.value).toBe(800)
|
||||
expect(result.cropY.value).toBe(800)
|
||||
|
||||
result.handleDragEnd(createPointerEvent('pointerup', 500, 500))
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleResizeStart/Move/End', () => {
|
||||
it('does nothing when imageUrl is null', () => {
|
||||
const { handleResizeStart, cropWidth } = setup({ width: 200 })
|
||||
|
||||
handleResizeStart(createPointerEvent('pointerdown', 50, 50), 'se')
|
||||
|
||||
expect(cropWidth.value).toBe(200)
|
||||
})
|
||||
|
||||
it('ignores resize move when not resizing', () => {
|
||||
const { handleResizeMove, cropWidth } = setup({ width: 200 })
|
||||
|
||||
handleResizeMove(createPointerEvent('pointermove', 300, 300))
|
||||
|
||||
expect(cropWidth.value).toBe(200)
|
||||
})
|
||||
|
||||
it('ignores resize end when not resizing', () => {
|
||||
const { handleResizeEnd } = setup()
|
||||
|
||||
handleResizeEnd(createPointerEvent('pointerup', 200, 200))
|
||||
})
|
||||
|
||||
it('resizes from the right edge', () => {
|
||||
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 25, 0))
|
||||
|
||||
expect(result.cropWidth.value).toBe(250)
|
||||
expect(result.cropX.value).toBe(100)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 25, 0))
|
||||
})
|
||||
|
||||
it('resizes from the bottom edge', () => {
|
||||
// effectiveScale = 0.5, so pointer delta 40px -> natural 80px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(
|
||||
createPointerEvent('pointerdown', 0, 0),
|
||||
'bottom'
|
||||
)
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 0, 40))
|
||||
|
||||
expect(result.cropHeight.value).toBe(280)
|
||||
expect(result.cropY.value).toBe(100)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 0, 40))
|
||||
})
|
||||
|
||||
it('resizes from left edge, moving x and shrinking width', () => {
|
||||
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 200,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'left')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 50, 0))
|
||||
|
||||
// delta = 50/0.5 = 100 natural px; newX = 200+100 = 300, newW = 400-100 = 300
|
||||
expect(result.cropX.value).toBe(300)
|
||||
expect(result.cropWidth.value).toBe(300)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 50, 0))
|
||||
})
|
||||
|
||||
it('enforces MIN_CROP_SIZE when resizing', () => {
|
||||
// effectiveScale = 0.5, so pointer -200px -> natural -400px
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', -200, 0))
|
||||
|
||||
// MIN_CROP_SIZE = 16
|
||||
expect(result.cropWidth.value).toBe(16)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', -200, 0))
|
||||
})
|
||||
|
||||
it('performs constrained resize with locked ratio from se corner', () => {
|
||||
const result = setupWithImage(1000, 1000, 500, 500, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
result.imageUrl.value = 'http://example.com/img.png'
|
||||
result.selectedRatio.value = '1:1'
|
||||
|
||||
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'se')
|
||||
result.handleResizeMove(createPointerEvent('pointermove', 100, 100))
|
||||
|
||||
// Both dimensions should grow equally for 1:1 ratio
|
||||
expect(result.cropWidth.value).toBe(result.cropHeight.value)
|
||||
expect(result.cropWidth.value).toBeGreaterThan(200)
|
||||
|
||||
result.handleResizeEnd(createPointerEvent('pointerup', 100, 100))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputImageUrl (via imageUrl)', () => {
|
||||
it('returns null when node is not found', () => {
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns URL from nodeOutputStore when node has output', () => {
|
||||
const mockSourceNode = { isSubgraphNode: () => false }
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(store.getNodeImageUrls).mockReturnValue([
|
||||
'http://example.com/output.png'
|
||||
])
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBe('http://example.com/output.png')
|
||||
})
|
||||
|
||||
it('returns null when source node has no output', () => {
|
||||
const mockSourceNode = { isSubgraphNode: () => false }
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(store.getNodeImageUrls).mockReturnValue(undefined)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when node has no input node', () => {
|
||||
const mockNode = createMockNode()
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves subgraph node output link', () => {
|
||||
const resolvedOutputNode = { isSubgraphNode: () => false }
|
||||
const mockSourceNode = {
|
||||
isSubgraphNode: () => true,
|
||||
resolveSubgraphOutputLink: vi
|
||||
.fn()
|
||||
.mockReturnValue({ outputNode: resolvedOutputNode })
|
||||
}
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
|
||||
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(store.getNodeImageUrls).mockReturnValue([
|
||||
'http://example.com/subgraph.png'
|
||||
])
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBe('http://example.com/subgraph.png')
|
||||
})
|
||||
|
||||
it('returns null when subgraph resolution fails (no link)', () => {
|
||||
const mockSourceNode = {
|
||||
isSubgraphNode: () => true,
|
||||
resolveSubgraphOutputLink: vi.fn()
|
||||
}
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
|
||||
getInputLink: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when subgraph resolves to no output node', () => {
|
||||
const mockSourceNode = {
|
||||
isSubgraphNode: () => true,
|
||||
resolveSubgraphOutputLink: vi.fn().mockReturnValue({ outputNode: null })
|
||||
}
|
||||
const mockNode = createMockNode({
|
||||
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
|
||||
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
|
||||
})
|
||||
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateDisplayedDimensions (via handleImageLoad)', () => {
|
||||
it('calculates scale for landscape image in smaller container', () => {
|
||||
const result = setupWithImage(1000, 500, 500, 400)
|
||||
|
||||
// Landscape image: imageAspect=2 > containerAspect=1.25
|
||||
// width-constrained: displayedWidth=500, scaleFactor=0.5
|
||||
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.5}px`)
|
||||
})
|
||||
|
||||
it('calculates scale for portrait image in wider container', () => {
|
||||
const result = setupWithImage(500, 1000, 600, 400)
|
||||
|
||||
// Portrait: imageAspect=0.5 < containerAspect=1.5
|
||||
// height-constrained: displayedWidth=200, scaleFactor=0.4
|
||||
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.4}px`)
|
||||
})
|
||||
|
||||
it('handles zero natural dimensions gracefully', () => {
|
||||
const result = setupWithImage(0, 0, 500, 400, {
|
||||
width: 512,
|
||||
height: 512
|
||||
})
|
||||
|
||||
// scaleFactor should default to 1
|
||||
expect(result.cropBoxStyle.value.width).toBe(`${512}px`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('calls resolveNode with the given nodeId', () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(resolveNode).mockReturnValue(mockNode)
|
||||
|
||||
setup()
|
||||
|
||||
expect(resolveNode).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('sets node to null when resolveNode returns undefined', () => {
|
||||
vi.mocked(resolveNode).mockReturnValue(undefined)
|
||||
|
||||
const { imageUrl } = setup()
|
||||
|
||||
expect(resolveNode).toHaveBeenCalledWith(1)
|
||||
expect(imageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user