Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
c427749c9a test: add unit tests for useImageCrop composable 2026-04-10 15:14:33 -07:00
Alexander Brown
0353524e6f refactor: standardize Page Object locators as public readonly instead of getters (#11135)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Convert ~120 getter-based locators across 18 browser test fixture
files to `public readonly` constructor-assigned properties
- Removes unnecessary indirection, makes object shape explicit, and
improves IDE auto-complete / type inference
- Keeps lazy-init getters (`??=`), computed properties, and `private get
page()` convenience accessors as getters

## Changes

**`browser_tests/fixtures/components/`** (6 files):
`ComfyNodeSearchBox`, `ContextMenu`, `SettingDialog`, `SignInDialog`,
`SidebarTab` (all 6 classes), `Topbar`

**`browser_tests/fixtures/`** (4 files): `ComfyPage` (ComfyMenu.buttons,
ComfyPage.visibleToasts), `UserSelectPage`, `ComfyMouse`,
`VueNodeHelpers`

**`browser_tests/fixtures/helpers/`** (7 files): `AppModeHelper`,
`BuilderFooterHelper`, `BuilderSaveAsHelper`, `BuilderSelectHelper`,
`BuilderStepsHelper`, `ToastHelper`, `NodeOperationsHelper`

**`browser_tests/fixtures/utils/`** (1 file): `vueNodeFixtures`

## Validation

- `pnpm typecheck` 
- `pnpm typecheck:browser` 
- `pnpm exec eslint browser_tests/fixtures/` 
- All pre-commit hooks pass (oxfmt, oxlint, eslint, typecheck,
typecheck:browser) 
- No visual/manual verification needed — changes are test fixture
locator declarations only (no UI or runtime behavior change)

Fixes #11131

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11135-refactor-standardize-Page-Object-locators-as-public-readonly-instead-of-getters-33e6d73d3650819690cbc639f3d30daf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-10 21:10:17 +00:00
20 changed files with 1078 additions and 555 deletions

View File

@@ -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).

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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')
}
}

View File

@@ -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.

View File

@@ -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()
}

View File

@@ -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> {

View File

@@ -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'

View File

@@ -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()

View File

@@ -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')
}
}

View File

@@ -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}")`)

View File

@@ -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").

View File

@@ -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 })
}

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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')

View File

@@ -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> {

View File

@@ -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> {

View 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()
})
})
})