Compare commits

...

7 Commits

Author SHA1 Message Date
Alexander Brown
3043b181d7 refactor: extract composables from VTU holdout components, complete VTL migration (#10966)
## Summary

Extract internal logic from the 2 remaining VTU holdout components into
composables, enabling full VTL migration.

## Changes

- **What**: Extract `useProcessedWidgets` from `NodeWidgets.vue`
(486→135 LOC) and `useWidgetSelectItems`/`useWidgetSelectActions` from
`WidgetSelectDropdown.vue` (563→170 LOC). Rewrite both component test
files as composable unit tests + slim behavioral VTL tests. Remove
`@vue/test-utils` devDependency.
- **Dependencies**: Removes `@vue/test-utils`

## Review Focus

- Composable extraction is mechanical — no logic changes, just moving
code into testable units
- `useProcessedWidgets` handles widget deduplication, promotion border
styling, error detection, and identity resolution (~290 LOC)
- `useWidgetSelectItems` handles the full computed chain from widget
values → dropdown items including cloud asset mode and multi-output job
resolution (~350 LOC)
- `useWidgetSelectActions` handles selection resolution and file upload
(~120 LOC)
- 40 new composable-level unit tests replace 13 `wrapper.vm.*` accesses
across the 2 holdout files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10966-refactor-extract-composables-from-VTU-holdout-components-complete-VTL-migration-33c6d73d36508148a3a4ccf346722d6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:04:16 -07:00
Alexander Brown
8c9328c1b2 feat: add eslint-plugin-playwright via oxlint JS plugins (#11136)
## Summary

Add eslint-plugin-playwright as an oxlint JS plugin scoped to
browser_tests/, enforcing Playwright best practices at lint time.

## Changes

- **What**: Configure eslint-plugin-playwright@2.10.1 via oxlint's alpha
`jsPlugins` field (`.oxlintrc.json` override scoped to
`browser_tests/**/*.ts`). 18 recommended rules +
`prefer-native-locators` + `require-to-pass-timeout` at error severity.
All 173 initial violations resolved (config, auto-fix, manual fixes).
`no-force-option` set to off — 28 violations need triage (canvas overlay
workarounds vs unnecessary force) in a dedicated PR.
- **Dependencies**: `eslint-plugin-playwright@^2.10.1` (devDependency,
required by oxlint jsPlugins at runtime)

## Review Focus

- `.oxlintrc.json` override structure — this is the first use of
oxlint's JS plugins alpha feature in this repo
- Manual fixes in spec files: `waitForSelector` → `locator.waitFor`,
deprecated page methods → locator equivalents, `toPass()` timeout
additions
- Compound CSS selectors replaced with `.and()` (Playwright native
locator composition) to avoid `prefer-native-locators` suppressions
- Lint script changes in `package.json` to include `browser_tests/` in
oxlint targets

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-11 01:25:14 +00:00
Christian Byrne
577f373cde fix: auto fit-to-view on first subgraph entry (#10995)
## Summary

Auto-fit viewport to subgraph content on first entry so interior nodes
are immediately visible.

## Changes

- **What**: On cache miss in `restoreViewport()`, call `fitView()` via
`requestAnimationFrame` instead of silently returning. Existing
cache-hit path (revisiting a subgraph) is unchanged.

## Review Focus

The `anyItemOverlapsRect` guard in `app.ts` (workflow load path) is
intentionally **not** touched — it serves a different purpose
(respecting `extra.ds` on workflow load). This fix only affects subgraph
navigation transitions where there is no saved viewport to respect.

Fixes #8173

## Screenshots (if applicable)

N/A — viewport positioning fix; before: empty canvas on subgraph entry,
after: nodes visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10995-fix-auto-fit-to-view-on-first-subgraph-entry-33d6d73d365081f3a9b3cc2124979624)
by [Unito](https://www.unito.io)
2026-04-11 00:45:38 +00:00
Christian Byrne
44f88027b6 fix: debounce reconnecting toast to prevent false-positive banner (#10997)
## Summary

Debounce the reconnecting toast with a 1.5s grace period so brief
WebSocket blips don't flash a persistent error banner.

## Problem

After #9543 made all error toasts sticky (no auto-dismiss), brief
WebSocket disconnections (<1s) would show a persistent "Reconnecting..."
error banner that stays until the `reconnected` event fires. On staging,
users see this banner even though jobs are actively executing.

## Changes

- Extract reconnection toast logic from `GraphView.vue` into
`useReconnectingNotification` composable
- Add 1.5s delay (via `useTimeoutFn` from VueUse) before showing the
reconnecting toast
- If `reconnected` fires within the delay window, suppress both the
error and success toasts entirely
- Clean up unused `useToast`/`useI18n` imports from `GraphView.vue`

## Testing

- Sub-1.5s disconnections: no toast shown
- Longer disconnections (>1.5s): error toast shown, cleared with success
toast on reconnect
- Setting `Comfy.Toast.DisableReconnectingToast`: no toasts shown at all
- Multiple rapid `reconnecting` events: only one toast shown

6 unit tests covering all scenarios.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10997-fix-debounce-reconnecting-toast-to-prevent-false-positive-banner-33d6d73d3650810289e8f57c046972f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 00:43:42 +00:00
Comfy Org PR Bot
5d07de1913 1.44.2 (#11151)
Patch version increment to 1.44.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11151-1-44-2-33f6d73d3650815c9767fa5668e67a47)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-11 00:41:25 +00:00
Johnpaul Chiwetelu
f0ae91de43 test: add e2e coverage for alt+drag duplicate and meta multi-select drag (#10994)
## Summary

Add Playwright coverage for two previously-untested canvas gestures:
Alt+drag to duplicate a regular node, and holding Meta across
click-click-click-drag to move multiple selected Vue nodes together.

## Changes

- **What**:
- `browser_tests/tests/interaction.spec.ts` — new `Node Duplication`
sub-describe with `Can duplicate a regular node via Alt+drag`. Asserts
CLIPTextEncode count goes 2 → 3 via poll, original node still exists.
Exercises the legacy canvas path at
`src/lib/litegraph/src/LGraphCanvas.ts:2434-2458`, which was only tested
for subgraph nodes before.
- `browser_tests/tests/vueNodes/interactions/node/move.spec.ts` — new
`should move all selected nodes together when dragging one with Meta
held`. Holds Meta for the entire sequence (3× `click({ modifiers:
['Meta'] })` + drag), asserts selection stays at 3 and all three nodes
move by the same delta. Exercises
`src/renderer/extensions/vueNodes/layout/useNodeDrag.ts:54-191`.
- Small refactor: `getLoadCheckpointHeaderPos` now delegates to a
reusable `getHeaderPos(comfyPage, title)` helper. Added `deltaBetween`
and `expectSameDelta` utilities (stricter than `expectPosChanged`, which
only checks `> 0`).


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10994-test-add-e2e-coverage-for-alt-drag-duplicate-and-meta-multi-select-drag-33d6d73d3650812dbf15c7053f44f0fd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 00:18:16 +00:00
Alexander Brown
fb8025c49f chore: disable vitest/require-mock-type-parameters oxlint rule (#11146)
## Summary

Disables the `vitest/require-mock-type-parameters` oxlint rule,
eliminating all 2,813 lint warnings in the codebase.

## Details

Every warning came from this single rule requiring explicit type
parameters on `vi.fn()` calls. Investigation showed:

- **85% are bare `vi.fn()`** — no type info available without manual
cross-referencing
- The rule's auto-fixer is **declared but not implemented** — `lint:fix`
doesn't help
- No existing codemods exist for this
- A full manual sweep would take 3–5 days across ~210 test files

## Test Plan

- `pnpm lint` passes with 0 warnings, 0 errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11146-fix-disable-vitest-require-mock-type-parameters-oxlint-rule-33e6d73d36508186bf1cdc2ce6d2ba57)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 00:18:09 +00:00
111 changed files with 3301 additions and 2042 deletions

View File

@@ -84,6 +84,7 @@
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
@@ -116,13 +117,60 @@
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],
"rules": {
"typescript/no-explicit-any": "error",
"no-async-promise-executor": "error",
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error"
"unicorn/no-empty-file": "error",
"playwright/consistent-spacing-between-blocks": "error",
"playwright/expect-expect": [
"error",
{
"assertFunctionNames": [
"recordMeasurement",
"logMeasurement",
"builderSaveAs"
],
"assertFunctionPatterns": [
"^expect",
"^assert",
"^verify",
"^searchAndExpect",
"waitForOpen",
"waitForClosed",
"waitForRequest"
]
}
],
"playwright/max-nested-describe": "error",
"playwright/no-duplicate-hooks": "error",
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "off",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",
"playwright/no-unsafe-references": "error",
"playwright/no-unused-locators": "error",
"playwright/no-useless-await": "error",
"playwright/no-useless-not": "error",
"playwright/no-wait-for-navigation": "error",
"playwright/no-wait-for-selector": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/prefer-hooks-on-top": "error",
"playwright/prefer-locator": "error",
"playwright/prefer-to-have-count": "error",
"playwright/prefer-to-have-length": "error",
"playwright/prefer-web-first-assertions": "error",
"playwright/prefer-native-locators": "error",
"playwright/require-to-pass-timeout": "error",
"playwright/valid-expect": "error",
"playwright/valid-expect-in-promise": "error",
"playwright/valid-title": "error"
}
}
]

View File

@@ -321,7 +321,7 @@ export class ComfyPage {
// window.app.extensionManager => GraphView ready
window.app && window.app.extensionManager
)
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
await this.nextFrame()
}
@@ -371,7 +371,7 @@ export class ComfyPage {
}
async closeMenu() {
await this.page.click('button.comfy-close-menu-btn')
await this.page.locator('button.comfy-close-menu-btn').click()
await this.nextFrame()
}

View File

@@ -37,7 +37,7 @@ export class VueNodeHelpers {
*/
getNodeByTitle(title: string): Locator {
return this.page.locator('[data-node-id]').filter({
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
has: this.page.getByTestId('node-title').filter({ hasText: title })
})
}
@@ -146,7 +146,7 @@ export class VueNodeHelpers {
expectedCount
)
} else {
await this.page.waitForSelector('[data-node-id]')
await this.page.locator('[data-node-id]').first().waitFor()
}
}

View File

@@ -52,6 +52,6 @@ export class SettingDialog extends BaseDialog {
name: 'About'
})
await aboutButton.click()
await this.page.waitForSelector('.about-container')
await this.page.locator('.about-container').waitFor()
}
}

View File

@@ -301,7 +301,9 @@ export class AssetsSidebarTab extends SidebarTab {
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.assetCards = page
.getByRole('button')
.and(page.locator('[data-selected]'))
this.selectedCards = page.locator('[data-selected="true"]')
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'

View File

@@ -53,7 +53,7 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.locator('[data-testid="linear-widgets"]')
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })

View File

@@ -157,7 +157,7 @@ export class SubgraphHelper {
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', {
await this.page.locator('.litemenu-entry').first().waitFor({
state: 'visible',
timeout: 5000
})

View File

@@ -14,10 +14,11 @@ function makeMatcher<T>(
) {
await expect(async () => {
const value = await getValue(node)
const assertion = this.isNot
? expect(value, 'Node is ' + type).not
: expect(value, 'Node is not ' + type)
assertion.toBeTruthy()
if (this.isNot) {
expect(value, 'Node is ' + type).not.toBeTruthy()
} else {
expect(value, 'Node is not ' + type).toBeTruthy()
}
}).toPass({ timeout: 5000, ...options })
return {
pass: !this.isNot,

View File

@@ -15,13 +15,11 @@ export class VueNodeFixture {
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.title = locator.getByTestId('node-title')
this.titleInput = locator.getByTestId('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.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
}

View File

@@ -16,7 +16,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
test('Build app button is visible when no outputs selected', async ({
@@ -26,7 +26,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
})
test('Empty workflow and build app are hidden when app has outputs', async ({
@@ -35,8 +35,8 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
@@ -46,7 +46,7 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).not.toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
})
test('Load template opens template selector', async ({ comfyPage }) => {

View File

@@ -11,7 +11,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
})
@@ -35,7 +35,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
})
test('should switch between shortcuts and terminal panels', async ({
@@ -55,7 +55,7 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
await expect(logsTab).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).not.toBeVisible()
).toBeHidden()
})
test('should persist Logs tab content in bottom panel', async ({

View File

@@ -10,11 +10,11 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
})
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
@@ -182,7 +182,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
})
test('should display shortcuts in organized columns', async ({
@@ -192,9 +192,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await bottomPanel.keyboardShortcutsButton.click()
await expect(
comfyPage.page.locator('[data-testid="shortcuts-columns"]')
).toBeVisible()
await expect(comfyPage.page.getByTestId('shortcuts-columns')).toBeVisible()
const subcategoryTitles = bottomPanel.shortcuts.subcategoryTitles
await expect.poll(() => subcategoryTitles.count()).toBeGreaterThanOrEqual(2)
@@ -205,7 +203,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
}) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
await comfyPage.page.keyboard.press('Control+Shift+KeyK')

View File

@@ -22,7 +22,7 @@ async function saveCloseAndReopenAsApp(
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).not.toBeVisible()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)

View File

@@ -31,7 +31,7 @@ async function dismissSuccessDialog(
) {
const btn = button === 'close' ? saveAs.closeButton : saveAs.dismissButton
await btn.click()
await expect(saveAs.successDialog).not.toBeVisible()
await expect(saveAs.successDialog).toBeHidden()
}
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
@@ -113,7 +113,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
@@ -121,7 +121,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.appMode.footer.exitBuilder()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})
test('Save button directly saves for previously saved workflow', async ({
@@ -141,7 +141,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await footer.saveButton.click()
await comfyPage.nextFrame()
await expect(saveAs.dialog).not.toBeVisible()
await expect(saveAs.dialog).toBeHidden()
await expect(footer.saveButton).toBeDisabled()
})
@@ -253,7 +253,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
await comfyPage.appMode.saveAs.viewAppButton.click()
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowActiveAppMode())
@@ -271,9 +271,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
)
await comfyPage.appMode.saveAs.exitBuilderButton.click()
await expect(comfyPage.appMode.saveAs.successDialog).not.toBeVisible()
await expect(comfyPage.appMode.saveAs.successDialog).toBeHidden()
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})
test('save as with different mode does not modify the original workflow', async ({
@@ -327,7 +327,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(appMode.saveAs.overwriteDialog).toBeVisible()
await appMode.saveAs.overwriteButton.click()
await expect(appMode.saveAs.overwriteDialog).not.toBeVisible()
await expect(appMode.saveAs.overwriteDialog).toBeHidden()
await expect(appMode.saveAs.successMessage).toBeVisible()

View File

@@ -70,7 +70,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await expect(menu).toBeVisible()
await trigger.click()
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(menu).toBeHidden()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
@@ -81,7 +81,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await expect(menu).toBeVisible()
await handItem.click()
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(menu).toBeHidden()
})
test('closes when Escape is pressed', async ({ comfyPage }) => {
@@ -91,7 +91,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await expect(menu).toBeVisible()
await selectItem.press('Escape')
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(menu).toBeHidden()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
})
@@ -197,7 +197,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await selectItem.press('ArrowDown')
await handItem.press('Escape')
await comfyPage.nextFrame()
await expect(menu).not.toBeVisible()
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
})
})

View File

@@ -38,13 +38,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await expect(selectedButton).toBeHidden()
await comfyPage.canvas.press(key)
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await expect(selectedButton).not.toBeVisible()
await expect(selectedButton).toBeHidden()
})
}
})
@@ -172,7 +172,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
@@ -189,7 +189,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await expect(minimap).not.toBeVisible()
await expect(minimap).toBeHidden()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await expect(minimap).toBeVisible()
@@ -198,13 +198,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await expect(comfyPage.bottomPanel.root).toBeHidden()
await comfyPage.page.keyboard.press('Control+Backquote')
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await expect(comfyPage.bottomPanel.root).toBeHidden()
})
})

View File

@@ -11,9 +11,7 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
@@ -26,17 +24,16 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
const settingsLocator = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
await expect(settingsLocator).toBeHidden()
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5
await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
await test.step('Setting should persist', async () => {
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.Graph.ZoomSpeed'))
@@ -49,9 +46,7 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.press('Control+,')
// Open the keybinding tab
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
await expect(settingsDialog).toBeVisible()
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })

View File

@@ -307,7 +307,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
await searchInput.fill('Test Pack B')
await expect(dialog.getByText('Test Pack B')).toBeVisible()
await expect(dialog.getByText('Test Pack A')).not.toBeVisible()
await expect(dialog.getByText('Test Pack A')).toBeHidden()
})
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
@@ -360,7 +360,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
await expect(dialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
})
test('Empty search shows no results message', async ({ comfyPage }) => {

View File

@@ -60,7 +60,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
})
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
@@ -83,7 +83,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
})
await dialog.getByLabel('Close').click()
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
@@ -106,7 +106,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
})
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
@@ -114,7 +114,7 @@ test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog).toBeVisible()

View File

@@ -61,7 +61,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
await expect(dialog.root).toBeVisible()
await dialog.close()
await expect(dialog.root).not.toBeVisible()
await expect(dialog.root).toBeHidden()
})
test('Escape key closes dialog', async ({ comfyPage }) => {
@@ -70,7 +70,7 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
await expect(dialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).not.toBeVisible()
await expect(dialog.root).toBeHidden()
})
test('Search filters settings list', async ({ comfyPage }) => {

View File

@@ -10,7 +10,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/collapsed_multiline')
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible()
await expect(textareaWidget).toBeHidden()
})
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
@@ -25,8 +25,8 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
for (const node of nodes) {
await node.click('collapse')
}
await expect(firstMultiline).not.toBeVisible()
await expect(lastMultiline).not.toBeVisible()
await expect(firstMultiline).toBeHidden()
await expect(lastMultiline).toBeHidden()
})
test(
@@ -35,7 +35,7 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
}
)

View File

@@ -74,7 +74,7 @@ test.describe('Error dialog', () => {
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
await expect(errorDialog.locator('pre')).not.toBeVisible()
await expect(errorDialog.locator('pre')).toBeHidden()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
@@ -83,7 +83,7 @@ test.describe('Error dialog', () => {
await expect(reportPre).toHaveText(/\S/)
await expect(
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
).not.toBeVisible()
).toBeHidden()
})
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({

View File

@@ -100,7 +100,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
@@ -112,10 +112,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
})
})
@@ -156,7 +156,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -168,7 +168,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
})
test('"Dismiss" closes overlay without opening panel', async ({
@@ -181,10 +181,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -195,7 +193,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
})
})
})

View File

@@ -69,7 +69,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(flags?.data).not.toBeNull()
expect(flags?.data).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.data?.supports_preview_metadata).toBe('boolean')
}).toPass()
}).toPass({ timeout: 5000 })
// Verify server sent feature flags back
await expect(async () => {
@@ -82,7 +82,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(flags).toHaveProperty('max_upload_size')
expect(typeof flags?.max_upload_size).toBe('number')
expect(Object.keys(flags ?? {}).length).toBeGreaterThan(0)
}).toPass()
}).toPass({ timeout: 5000 })
await newPage.close()
})
@@ -102,7 +102,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(typeof flags.supports_preview_metadata).toBe('boolean')
expect(flags).toHaveProperty('max_upload_size')
expect(typeof flags.max_upload_size).toBe('number')
}).toPass()
}).toPass({ timeout: 5000 })
})
test('serverSupportsFeature method works with real backend flags', async ({
@@ -182,7 +182,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
)
expect(typeof maxUpload).toBe('number')
expect(maxUpload as number).toBeGreaterThan(0)
}).toPass()
}).toPass({ timeout: 5000 })
// Test getServerFeature with default value for non-existent feature
await expect
@@ -210,7 +210,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(typeof features.supports_preview_metadata).toBe('boolean')
expect(features).toHaveProperty('max_upload_size')
expect(Object.keys(features).length).toBeGreaterThan(0)
}).toPass()
}).toPass({ timeout: 5000 })
})
test('Client feature flags are immutable', async ({ comfyPage }) => {
@@ -348,14 +348,14 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
expect(flags).toHaveProperty('supports_preview_metadata')
expect(typeof flags?.supports_preview_metadata).toBe('boolean')
expect(flags).toHaveProperty('max_upload_size')
}).toPass()
}).toPass({ timeout: 5000 })
// Verify feature flags were received and API was initialized
await expect(async () => {
const readiness = await newPage.evaluate(() => window.__appReadiness)
expect(readiness?.featureFlagsReceived).toBe(true)
expect(readiness?.apiInitialized).toBe(true)
}).toPass()
}).toPass({ timeout: 5000 })
await newPage.close()
})

View File

@@ -14,12 +14,12 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
})
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible()
@@ -29,7 +29,7 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await expect(comfyPage.menu.sideToolbar).toBeVisible()
@@ -41,7 +41,7 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true)
await expect(topMenu).not.toBeVisible()
await expect(topMenu).toBeHidden()
})
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
@@ -52,12 +52,12 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
})
})

View File

@@ -109,6 +109,6 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
expect(r.switchOutputLinkIds).toEqual(
expect.arrayContaining([r.cfg85LinkId, r.cfg86LinkId])
)
}).toPass()
}).toPass({ timeout: 5000 })
})
})

View File

@@ -94,6 +94,6 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
await backdrop.click()
// Modal should be hidden
await expect(zoomModal).not.toBeVisible()
await expect(zoomModal).toBeHidden()
})
})

View File

@@ -88,7 +88,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
await comfyPage.page
.locator('.p-tree-node-label.tree-explorer-node-label')
.first()
.hover()
await expect(
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
@@ -99,6 +102,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
.click()
})
})
test(
'Can be added to canvas using search',
{ tag: '@screenshot' },
@@ -154,7 +158,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.nextFrame()
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).not.toBeVisible()
await expect(manage1.root).toBeHidden()
const manage2 = await group2.manageGroupNode()
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
@@ -241,7 +245,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
).toBeHidden()
})
test.describe('Copy and paste', () => {
@@ -349,6 +353,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({

View File

@@ -46,7 +46,7 @@ test.describe('Image Compare', () => {
await expect(node).toContainText('No images to compare')
await expect(node.locator('img')).toHaveCount(0)
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
await expect(node.getByRole('presentation')).toHaveCount(0)
}
)
@@ -67,7 +67,7 @@ test.describe('Image Compare', () => {
await expect(beforeImg).toBeVisible()
await expect(afterImg).toBeVisible()
const handle = node.locator('[role="presentation"]')
const handle = node.getByRole('presentation')
await expect(handle).toBeVisible()
expect(

View File

@@ -180,6 +180,48 @@ test.describe('Node Interaction', () => {
})
})
test.describe('Node Duplication', () => {
test.beforeEach(async ({ comfyPage }) => {
// Pin this suite to the legacy canvas path so Alt+drag exercises
// LGraphCanvas, not the Vue node drag handler.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
})
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
const before = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
before,
'Expected exactly 2 CLIPTextEncode nodes in default graph'
).toHaveLength(2)
const target = before[0]
const pos = await target.getPosition()
const src = { x: pos.x + 16, y: pos.y + 16 }
await comfyPage.page.mouse.move(src.x, src.y)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(src.x + 120, src.y + 80, { steps: 20 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')).length
)
.toBe(3)
expect(await target.exists()).toBe(true)
})
})
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -1252,7 +1294,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Space + left-click drag should pan canvas', async ({ comfyPage }) => {
// Click canvas to focus it
await comfyPage.page.click('canvas')
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.down('Space')
@@ -1321,7 +1363,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
'panning'
)
await comfyPage.page.click('canvas')
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')

View File

@@ -25,7 +25,7 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="docked-job-history-action"]')
comfyPage.page.getByTestId('docked-job-history-action')
).toBeVisible()
})
@@ -34,9 +34,7 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
}) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
const action = comfyPage.page.getByTestId('docked-job-history-action')
await expect(action).toBeVisible()
await expect(action).not.toBeEmpty()
})
@@ -45,7 +43,7 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="show-run-progress-bar-action"]')
comfyPage.page.getByTestId('show-run-progress-bar-action')
).toBeVisible()
})
@@ -53,20 +51,18 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="clear-history-action"]')
comfyPage.page.getByTestId('clear-history-action')
).toBeVisible()
})
test('Clicking docked job history closes popover', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
const action = comfyPage.page.getByTestId('docked-job-history-action')
await expect(action).toBeVisible()
await action.click()
await expect(action).not.toBeVisible()
await expect(action).toBeHidden()
})
test('Clicking show run progress bar toggles setting', async ({
@@ -78,9 +74,7 @@ test.describe('Job History Actions', { tag: '@ui' }, () => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="show-run-progress-bar-action"]'
)
const action = comfyPage.page.getByTestId('show-run-progress-bar-action')
await action.click()
await expect

View File

@@ -14,48 +14,38 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
comfyPage.page.getByTestId('linear-workflow-info')
).toBeVisible()
})
test('Returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.canvas).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).not.toBeVisible()
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeHidden()
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible()
await expect(comfyPage.canvas).not.toBeVisible()
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
})
})

View File

@@ -101,7 +101,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
// Check initial state of bottom panel (it's initially hidden)
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
@@ -126,7 +126,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel.root).not.toBeVisible()
await expect(bottomPanel.root).toBeHidden()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
@@ -138,7 +138,7 @@ test.describe('Menu', { tag: '@ui' }, () => {
.click({ position: { x: viewport.width - 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()
await expect(menu).toBeHidden()
})
test('Displays keybinding next to item', async ({ comfyPage }) => {

View File

@@ -53,7 +53,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
await toggleButton.click()
await expect(minimapContainer).not.toBeVisible()
await expect(minimapContainer).toBeHidden()
await toggleButton.click()
await expect(minimapContainer).toBeVisible()
@@ -65,7 +65,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).not.toBeVisible()
await expect(minimapContainer).toBeHidden()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeVisible()
@@ -76,7 +76,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
await expect(minimap).toBeVisible()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).not.toBeVisible()
await expect(minimap).toBeHidden()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton

View File

@@ -39,9 +39,7 @@ test.describe(
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()

View File

@@ -40,13 +40,14 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
).toBeHidden()
// If the node's multiline text widget is visible, then it was loaded successfully
await expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(
1
)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/old_workflow_converted_input')
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -62,6 +63,7 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
expect(vaeInput!.link).toBeNull()
expect(convertedInput!.link).not.toBeNull()
})
test('Renamed converted input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/renamed_converted_widget')
const node = await comfyPage.nodeOps.getNodeRefById('3')
@@ -69,10 +71,12 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
const renamedInput = inputs.find((w) => w.name === 'breadth')
expect(renamedInput).toBeUndefined()
})
test('slider', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/simple_slider')
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
@@ -81,6 +85,7 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
'missing_nodes_converted_widget.png'
)
})
test('dynamically added input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -191,7 +191,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
).toBeVisible()
// Verify help page is no longer visible
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
await expect(helpPage.locator('.node-help-content')).toBeHidden()
})
})
@@ -505,7 +505,7 @@ This is English documentation.
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
await expect(helpPage.locator('.p-progressspinner')).toBeHidden()
// Should show some content even on error
await expect(helpPage).not.toHaveText('')

View File

@@ -203,7 +203,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
await comfyPage.page.keyboard.press('Escape')
// Verify the filter selection panel is hidden
await expect(panel.header).not.toBeVisible()
await expect(panel.header).toBeHidden()
// Verify the node search dialog is still visible
await expect(comfyPage.searchBox.input).toBeVisible()

View File

@@ -29,7 +29,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
@@ -48,7 +48,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
// Enter should add the first (selected) result
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
@@ -141,7 +141,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
// Enter selects and adds node
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())

View File

@@ -39,7 +39,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
@@ -56,7 +56,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
@@ -104,9 +104,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.locator(
'[data-testid="filter-chip"]'
)
const filterChip = searchBoxV2.dialog.getByTestId('filter-chip')
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
await expect
@@ -117,7 +115,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
await filterChip.getByTestId('chip-delete').click()
// Filter chip should be removed
await expect(filterChip).not.toBeVisible()
await expect(filterChip).toBeHidden()
await expect(searchBoxV2.results.first()).toBeVisible()
})
})

View File

@@ -202,6 +202,7 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
'domNodes'
])
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -13,5 +13,5 @@ export async function openErrorsTabViaSeeErrors(
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
}

View File

@@ -76,7 +76,7 @@ export class PropertiesPanelHelper {
async close(): Promise<void> {
if (await this.root.isVisible()) {
await this.closeButton.click()
await expect(this.root).not.toBeVisible()
await expect(this.root).toBeHidden()
}
}

View File

@@ -33,7 +33,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await comfyPage.actionbar.propertiesButton.click()
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(panel.errorsTabIcon).not.toBeVisible()
await expect(panel.errorsTabIcon).toBeHidden()
})
})
@@ -55,7 +55,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
const runtimePanel = comfyPage.page.getByTestId(
TestIds.dialogs.runtimeErrorPanel

View File

@@ -26,7 +26,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
}
test('Should show Find on GitHub and Copy buttons in error card', async ({

View File

@@ -104,6 +104,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
@@ -125,13 +126,13 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).not.toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
.click()
await expect(getStatusCard(comfyPage)).not.toBeVisible()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
@@ -146,7 +147,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).not.toBeVisible()
).toBeHidden()
})
})

View File

@@ -57,7 +57,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).not.toBeVisible()
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand

View File

@@ -74,7 +74,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).not.toBeVisible()
).toBeHidden()
})
test('Locate node button is visible for expanded pack nodes', async ({

View File

@@ -27,7 +27,7 @@ test.describe('Properties panel - Node selection', () => {
})
test('should not show Nodes tab for single node', async () => {
await expect(panel.getTab('Nodes')).not.toBeVisible()
await expect(panel.getTab('Nodes')).toBeHidden()
})
test('should display node widgets in Parameters tab', async () => {
@@ -65,7 +65,7 @@ test.describe('Properties panel - Node selection', () => {
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Info')).not.toBeVisible()
await expect(panel.getTab('Info')).toBeHidden()
})
})

View File

@@ -42,8 +42,8 @@ test.describe('Properties panel - Node settings', () => {
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
await panel.getNodeStateButton('Normal').click()
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
await expect(nodeLocator.getByText('Bypassed')).toBeHidden()
await expect(nodeLocator.getByText('Muted')).toBeHidden()
})
})
@@ -114,9 +114,7 @@ test.describe('Properties panel - Node settings', () => {
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
await panel.pinnedSwitch.click()
await expect(
nodeLocator.getByTestId('node-pin-indicator')
).not.toBeVisible()
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeHidden()
})
})
})

View File

@@ -11,7 +11,7 @@ test.describe('Properties panel - Open and close', () => {
})
test('should open via actionbar toggle button', async ({ comfyPage }) => {
await expect(panel.root).not.toBeVisible()
await expect(panel.root).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
@@ -20,13 +20,13 @@ test.describe('Properties panel - Open and close', () => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.closeButton.click()
await expect(panel.root).not.toBeVisible()
await expect(panel.root).toBeHidden()
})
test('should close via close button after opening', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.close()
await expect(panel.root).not.toBeVisible()
await expect(panel.root).toBeHidden()
})
})

View File

@@ -34,7 +34,7 @@ test.describe('Properties panel - Title editing', () => {
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.titleEditIcon).not.toBeVisible()
await expect(panel.titleEditIcon).toBeHidden()
})
test('should not show pencil icon when nothing is selected', async ({
@@ -44,6 +44,6 @@ test.describe('Properties panel - Title editing', () => {
window.app!.canvas.deselectAll()
})
await expect(panel.panelTitle).toContainText('Workflow Overview')
await expect(panel.titleEditIcon).not.toBeVisible()
await expect(panel.titleEditIcon).toBeHidden()
})
})

View File

@@ -23,7 +23,7 @@ test.describe('Properties panel - Workflow Overview', () => {
})
test('should not show Info tab when nothing is selected', async () => {
await expect(panel.getTab('Info')).not.toBeVisible()
await expect(panel.getTab('Info')).toBeHidden()
})
test('should switch to Nodes tab and list all workflow nodes', async ({

View File

@@ -93,7 +93,7 @@ test.describe('Queue overlay', () => {
).toBeVisible()
await expect(
comfyPage.page.locator('[data-job-id="job-failed-1"]')
).not.toBeVisible()
).toBeHidden()
})
test('Toggling overlay again closes it', async ({ comfyPage }) => {
@@ -104,8 +104,6 @@ test.describe('Queue overlay', () => {
await toggle.click()
await expect(
comfyPage.page.locator('[data-job-id]').first()
).not.toBeVisible()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeHidden()
})
})

View File

@@ -72,8 +72,8 @@ test.describe('Release Notifications', () => {
).toBeVisible()
// Close help center by dismissable mask
await comfyPage.page.click('.help-center-backdrop')
await expect(helpMenu).not.toBeVisible()
await comfyPage.page.locator('.help-center-backdrop').click()
await expect(helpMenu).toBeHidden()
})
test('should not show release notifications when mocked (default behavior)', async ({
@@ -103,10 +103,10 @@ test.describe('Release Notifications', () => {
).toBeVisible()
// Should not show any popups or toasts
await expect(comfyPage.page.locator('.whats-new-popup')).not.toBeVisible()
await expect(comfyPage.page.locator('.whats-new-popup')).toBeHidden()
await expect(
comfyPage.page.locator('.release-notification-toast')
).not.toBeVisible()
).toBeHidden()
})
test('should handle release API errors gracefully', async ({ comfyPage }) => {
@@ -189,13 +189,13 @@ test.describe('Release Notifications', () => {
const whatsNewSection = comfyPage.page.getByTestId(
TestIds.dialogs.whatsNewSection
)
await expect(whatsNewSection).not.toBeVisible()
await expect(whatsNewSection).toBeHidden()
// Should not show any popups or toasts
await expect(comfyPage.page.locator('.whats-new-popup')).not.toBeVisible()
await expect(comfyPage.page.locator('.whats-new-popup')).toBeHidden()
await expect(
comfyPage.page.locator('.release-notification-toast')
).not.toBeVisible()
).toBeHidden()
})
test('should not make API calls when notifications are disabled', async ({
@@ -325,7 +325,7 @@ test.describe('Release Notifications', () => {
await expect(whatsNewSection).toBeVisible()
// Close help center
await comfyPage.page.click('.help-center-backdrop')
await comfyPage.page.locator('.help-center-backdrop').click()
// Disable notifications
await comfyPage.settings.setSetting(
@@ -337,7 +337,7 @@ test.describe('Release Notifications', () => {
await helpCenterButton.click()
// Verify "What's New?" section is now hidden
await expect(whatsNewSection).not.toBeVisible()
await expect(whatsNewSection).toBeHidden()
})
test('should handle edge case with empty releases and disabled notifications', async ({
@@ -381,6 +381,6 @@ test.describe('Release Notifications', () => {
const whatsNewSection = comfyPage.page.getByTestId(
TestIds.dialogs.whatsNewSection
)
await expect(whatsNewSection).not.toBeVisible()
await expect(whatsNewSection).toBeHidden()
})
})

View File

@@ -31,7 +31,7 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
// Wait for any asset card to appear (may contain img or video)
const assetCard = comfyPage.page
.locator('[role="button"]')
.getByRole('button')
.filter({ has: comfyPage.page.locator('img, video') })
.first()
@@ -56,13 +56,13 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
})
})

View File

@@ -135,7 +135,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -153,7 +153,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-pinned-node.png'
)
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
@@ -173,7 +173,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
@@ -181,7 +181,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -203,7 +203,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Pin")')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -214,7 +214,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await comfyPage.page.click('.litemenu-entry:has-text("Unpin")')
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -9,6 +9,7 @@ const test = comfyPageFixture
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
@@ -30,7 +31,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test('shows selection toolbox', async ({ comfyPage }) => {
// By default, selection toolbox should be enabled
await expect(comfyPage.selectionToolbox).not.toBeVisible()
await expect(comfyPage.selectionToolbox).toBeHidden()
// Select multiple nodes
await comfyPage.nodeOps.selectNodes([
@@ -86,7 +87,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(nodePos.x + 200, nodePos.y + 200)
await comfyPage.nextFrame()
await expect(comfyPage.selectionToolbox).not.toBeVisible()
await expect(comfyPage.selectionToolbox).toBeHidden()
})
test('shows border only with multiple selections', async ({ comfyPage }) => {
@@ -127,7 +128,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.workflow.loadWorkflow('groups/single_group')
// Select group + node should show bypass button
await comfyPage.page.focus('canvas')
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
@@ -141,7 +142,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).not.toBeVisible()
).toBeHidden()
})
test.describe('Color Picker', () => {
@@ -169,7 +170,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await blueColorOption.click()
// Dropdown should close after selection
await expect(colorPickerGroup).not.toBeVisible()
await expect(colorPickerGroup).toBeHidden()
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes

View File

@@ -204,7 +204,9 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await frameButton.click({ force: true })
await comfyPage.page
.getByRole('button', { name: /Frame Nodes/i })
.click({ force: true })
await comfyPage.nextFrame()
await expect
@@ -223,7 +225,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).not.toBeVisible()
await expect(frameButton).toBeHidden()
})
test('execute button visible when output node selected', async ({
@@ -253,6 +255,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const executeButton = comfyPage.page.getByRole('button', {
name: /Execute to selected output nodes/i
})
await expect(executeButton).not.toBeVisible()
await expect(executeButton).toBeHidden()
})
})

View File

@@ -47,12 +47,10 @@ test.describe(
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await comfyPage.page.click('[data-testid="more-options-button"]')
await moreOptionsBtn.click()
await comfyPage.nextFrame()
@@ -113,7 +111,7 @@ test.describe(
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
const blueSwatch = comfyPage.page.getByTitle('Blue')
await expect(blueSwatch.first()).toBeVisible()
await blueSwatch.first().click()
await comfyPage.nextFrame()
@@ -162,7 +160,7 @@ test.describe(
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
).toBeHidden()
})
test('closes More Options menu when clicking the button again (toggle)', async ({
@@ -191,7 +189,7 @@ test.describe(
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
).toBeHidden()
})
}
)

View File

@@ -192,6 +192,7 @@ test.describe('Assets sidebar - grid view display', () => {
// Imported tab should show the mocked files
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
test('Displays svg outputs', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
@@ -745,7 +746,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
await comfyPage.confirmDialog.delete.click()
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount - 1)
const successToast = comfyPage.page.locator('.p-toast-message-success')
@@ -767,7 +768,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
await comfyPage.confirmDialog.reject.click()
await expect(dialog).not.toBeVisible()
await expect(dialog).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount)
})
})

View File

@@ -118,7 +118,7 @@ test.describe('Model library sidebar - search', () => {
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
// Other models should not be visible
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).not.toBeVisible()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeHidden()
})
test('Clearing search restores folder view', async ({ comfyPage }) => {

View File

@@ -58,7 +58,7 @@ test.describe('Node library sidebar', () => {
// Hover over a node to display the preview
const nodeSelector = tab.nodeSelector('KSampler (Advanced)')
await comfyPage.page.hover(nodeSelector)
await comfyPage.page.locator(nodeSelector).hover()
// Verify the preview is displayed
await expect(tab.nodePreview).toBeVisible()
@@ -78,9 +78,9 @@ test.describe('Node library sidebar', () => {
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
}
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector, {
targetPosition
})
await comfyPage.page
.locator(nodeSelector)
.dragTo(comfyPage.page.locator(canvasSelector), { targetPosition })
await comfyPage.nextFrame()
// Verify the node is added to the canvas
@@ -102,7 +102,9 @@ test.describe('Node library sidebar', () => {
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(2)
// Hover on the bookmark node to display the preview
await comfyPage.page.hover('.node-lib-bookmark-tree-explorer .tree-leaf')
await comfyPage.page
.locator('.node-lib-bookmark-tree-explorer .tree-leaf')
.hover()
await expect(tab.nodePreview).toBeVisible()
})
@@ -220,6 +222,7 @@ test.describe('Node library sidebar', () => {
.click()
await expectBookmarks(comfyPage, [])
})
test('Can customize icon', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
@@ -247,6 +250,7 @@ test.describe('Node library sidebar', () => {
}
})
})
// If color is left as default, it should not be saved
test('Can customize icon (default field)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])

View File

@@ -42,11 +42,11 @@ test.describe('Node library sidebar V2', () => {
test('Search filters nodes in All tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.getNode('KSampler (Advanced)')).not.toBeVisible()
await expect(tab.getNode('KSampler (Advanced)')).toBeHidden()
await tab.searchInput.fill('KSampler')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
await expect(tab.getNode('CLIPLoader')).not.toBeVisible()
await expect(tab.getNode('CLIPLoader')).toBeHidden()
})
test('Drag node to canvas adds it', async ({ comfyPage }) => {

View File

@@ -27,9 +27,7 @@ test.describe('Workflow sidebar - search', () => {
await searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeHidden()
})
test('Clearing search restores all workflows', async ({ comfyPage }) => {
@@ -38,9 +36,7 @@ test.describe('Workflow sidebar - search', () => {
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeHidden()
await searchInput.fill('')
@@ -55,11 +51,7 @@ test.describe('Workflow sidebar - search', () => {
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('nonexistent_xyz')
await expect(
findWorkflow(comfyPage.page, 'alpha-workflow')
).not.toBeVisible()
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeHidden()
})
})

View File

@@ -265,7 +265,7 @@ test.describe('Workflows sidebar', () => {
// Dismiss the error overlay
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
// Load blank workflow
await comfyPage.menu.workflowsTab.open()
@@ -316,7 +316,7 @@ test.describe('Workflows sidebar', () => {
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
await comfyPage.nextFrame()
await comfyPage.contextMenu.clickMenuItem('Delete')
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
await expect(workflowsTab.getOpenedItem(filename)).toBeHidden()
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow'])
@@ -337,7 +337,7 @@ test.describe('Workflows sidebar', () => {
await comfyPage.confirmDialog.click('delete')
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
await expect(workflowsTab.getOpenedItem(filename)).toBeHidden()
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow'])

View File

@@ -52,11 +52,11 @@ test.describe(
await comfyPage.workflow.waitForDraftPersisted()
// Reload the page (draft auto-loads with hash preserved)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
await comfyPage.page.locator('.p-blockui-mask').waitFor({
state: 'hidden'
})
await comfyPage.nextFrame()

View File

@@ -131,7 +131,7 @@ test.describe(
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
const widgets = nodeBody.locator('.lg-node-widgets > div')
@@ -400,7 +400,7 @@ test.describe(
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
const nodeBody = subgraphVueNode.getByTestId('node-body-5')
await expect(nodeBody).toBeVisible()
await expect(
nodeBody.locator('.lg-node-widgets > div').first()

View File

@@ -132,7 +132,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
@@ -153,10 +155,12 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
await comfyPage.page.locator(SELECTORS.promptDialog).waitFor({
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
@@ -178,11 +182,13 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
await comfyPage.page.locator(SELECTORS.promptDialog).waitFor({
state: 'visible'
})
const renamedOutputName = 'renamed_output'
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(renamedOutputName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
@@ -209,11 +215,13 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
await comfyPage.page.locator(SELECTORS.promptDialog).waitFor({
state: 'visible'
})
const rightClickRenamedName = 'right_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(rightClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
@@ -270,7 +278,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
@@ -303,8 +313,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.locator(SELECTORS.promptDialog).fill('')
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
@@ -332,8 +344,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
RENAMED_SLOT_NAME
)
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
await comfyPage.page.locator(SELECTORS.promptDialog).fill('')
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(SECOND_RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
@@ -366,8 +380,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.locator(SELECTORS.promptDialog).fill('')
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill(RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
@@ -434,8 +450,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_LABEL)
await comfyPage.page.locator(SELECTORS.promptDialog).fill('')
await comfyPage.page.locator(SELECTORS.promptDialog).fill(RENAMED_LABEL)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
@@ -533,8 +549,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, 'my_custom_prompt')
await comfyPage.page.locator(SELECTORS.promptDialog).fill('')
await comfyPage.page
.locator(SELECTORS.promptDialog)
.fill('my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()

View File

@@ -40,6 +40,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
// page.route(), and change checkTemplateFileExists to use browser-context
// fetch (page.request.head bypasses Playwright routing).
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
// oxlint-disable-next-line playwright/no-skipped-test -- https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
@@ -185,8 +186,8 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await comfyPage.templates.content.waitFor({ state: 'visible' })
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
const templateGrid = comfyPage.page.getByTestId(
'template-workflows-content'
)
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
@@ -302,20 +303,18 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
// Wait for cards to load
await expect(
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
comfyPage.page.getByTestId('template-workflow-short-description')
).toBeVisible()
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
const shortDescCard = comfyPage.page.getByTestId(
'template-workflow-short-description'
)
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
const mediumDescCard = comfyPage.page.getByTestId(
'template-workflow-medium-description'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
const longDescCard = comfyPage.page.getByTestId(
'template-workflow-long-description'
)
await expect(shortDescCard).toBeVisible()
@@ -333,8 +332,8 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(longDesc).toContainText('much longer description')
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
const templateGrid = comfyPage.page.getByTestId(
'template-workflows-content'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(

View File

@@ -64,9 +64,9 @@ test.describe('Workflow tabs', () => {
await topbar.getTab(0).click({ button: 'right' })
// Reka UI ContextMenuContent gets data-state="open" when active
const contextMenu = comfyPage.page.locator(
'[role="menu"][data-state="open"]'
)
const contextMenu = comfyPage.page
.getByRole('menu')
.and(comfyPage.page.locator('[data-state="open"]'))
await expect(contextMenu).toBeVisible()
await expect(
@@ -86,9 +86,9 @@ test.describe('Workflow tabs', () => {
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await topbar.getTab(1).click({ button: 'right' })
const contextMenu = comfyPage.page.locator(
'[role="menu"][data-state="open"]'
)
const contextMenu = comfyPage.page
.getByRole('menu')
.and(comfyPage.page.locator('[data-state="open"]'))
await expect(contextMenu).toBeVisible()
await contextMenu

View File

@@ -112,7 +112,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
await dialog.open()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).not.toBeVisible()
await expect(dialog.root).toBeHidden()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {

View File

@@ -83,7 +83,7 @@ test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
// Expect no warning toast to be shown
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).not.toBeVisible()
).toBeHidden()
})
test('should persist dismissed state across sessions', async ({
@@ -121,6 +121,6 @@ test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
// The same warning from same versions should not be shown to the user again
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).not.toBeVisible()
).toBeHidden()
})
})

View File

@@ -143,7 +143,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(fixture.pinIndicator).not.toBeVisible()
await expect(fixture.pinIndicator).toBeHidden()
await expect.poll(() => nodeRef.isPinned()).toBe(false)
})
@@ -178,7 +178,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture.body).not.toBeVisible()
await expect(fixture.body).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Expand Node')
@@ -194,9 +194,7 @@ test.describe('Vue Node Context Menu', () => {
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeHidden()
})
})
@@ -309,9 +307,7 @@ test.describe('Vue Node Context Menu', () => {
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeHidden()
// Unpack the subgraph
await openContextMenu(comfyPage, 'New Subgraph')
@@ -320,7 +316,7 @@ test.describe('Vue Node Context Menu', () => {
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
).not.toBeVisible()
).toBeHidden()
})
test('should open properties panel via Edit Subgraph Widgets', async ({
@@ -433,7 +429,7 @@ test.describe('Vue Node Context Menu', () => {
for (const title of nodeTitles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await expect(fixture.pinIndicator).not.toBeVisible()
await expect(fixture.pinIndicator).toBeHidden()
}
})
@@ -474,8 +470,8 @@ test.describe('Vue Node Context Menu', () => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture1.body).not.toBeVisible()
await expect(fixture2.body).not.toBeVisible()
await expect(fixture1.body).toBeHidden()
await expect(fixture2.body).toBeHidden()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Expand Node')

View File

@@ -11,17 +11,22 @@ test.describe('Vue Node Moving', () => {
await comfyPage.vueNodes.waitForNodes()
})
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) => {
const loadCheckpointHeaderPos = await comfyPage.page
.getByText('Load Checkpoint')
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.boundingBox()
if (!loadCheckpointHeaderPos)
throw new Error('Load Checkpoint header not found')
return loadCheckpointHeaderPos
if (!box) throw new Error(`${title} header not found`)
return box
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
getHeaderPos(comfyPage, 'Load Checkpoint')
const expectPosChanged = async (pos1: Position, pos2: Position) => {
const diffX = Math.abs(pos2.x - pos1.x)
const diffY = Math.abs(pos2.y - pos1.y)
@@ -29,6 +34,16 @@ test.describe('Vue Node Moving', () => {
expect(diffY).toBeGreaterThan(0)
}
const deltaBetween = (before: Position, after: Position) => ({
x: after.x - before.x,
y: after.y - before.y
})
const expectSameDelta = (a: Position, b: Position, tol = 2) => {
expect(Math.abs(a.x - b.x)).toBeLessThanOrEqual(tol)
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -80,6 +95,73 @@ test.describe('Vue Node Moving', () => {
await expectPosChanged(headerPos, afterPos)
})
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {
const checkpointBefore = await getHeaderPos(comfyPage, 'Load Checkpoint')
const ksamplerBefore = await getHeaderPos(comfyPage, 'KSampler')
const latentBefore = await getHeaderPos(comfyPage, 'Empty Latent Image')
const dx = 120
const dy = 80
const clickNodeTitleWithMeta = async (title: string) => {
await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.click({ modifiers: ['Meta'] })
}
await comfyPage.page.keyboard.down('Meta')
try {
await clickNodeTitleWithMeta('Load Checkpoint')
await clickNodeTitleWithMeta('KSampler')
await clickNodeTitleWithMeta('Empty Latent Image')
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
// Re-fetch drag source after clicks in case the header reflowed.
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
const centerX = dragSrc.x + dragSrc.width / 2
const centerY = dragSrc.y + dragSrc.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
} finally {
await comfyPage.page.keyboard.up('Meta')
await comfyPage.nextFrame()
}
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
const checkpointAfter = await getHeaderPos(comfyPage, 'Load Checkpoint')
const ksamplerAfter = await getHeaderPos(comfyPage, 'KSampler')
const latentAfter = await getHeaderPos(comfyPage, 'Empty Latent Image')
// All three nodes should have moved together by the same delta.
// We don't assert the exact screen delta equals the dragged pixel delta,
// because canvas scaling and snap-to-grid can introduce offsets.
const checkpointDelta = deltaBetween(checkpointBefore, checkpointAfter)
const ksamplerDelta = deltaBetween(ksamplerBefore, ksamplerAfter)
const latentDelta = deltaBetween(latentBefore, latentAfter)
// Confirm an actual drag happened (not zero movement).
expect(Math.abs(checkpointDelta.x)).toBeGreaterThan(10)
expect(Math.abs(checkpointDelta.y)).toBeGreaterThan(10)
// Confirm all selected nodes moved by the same delta.
expectSameDelta(checkpointDelta, ksamplerDelta)
expectSameDelta(checkpointDelta, latentDelta)
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -50,6 +50,6 @@ test.describe('Vue Nodes Renaming', () => {
const editingTitleInput = comfyPage.page.getByTestId(
TestIds.node.titleInput
)
await expect(editingTitleInput).not.toBeVisible()
await expect(editingTitleInput).toBeHidden()
})
})

View File

@@ -30,7 +30,7 @@ test.describe('Vue Node Collapse', () => {
await comfyPage.nextFrame()
// Verify node content is hidden
await expect(body).not.toBeVisible()
await expect(body).toBeHidden()
await expect
.poll(async () => (await vueNode.boundingBox())?.height)
.toBeLessThan(expandedBoundingBox.height)

View File

@@ -24,7 +24,7 @@ test.describe('Vue Node Pin', () => {
await expect(pinIndicator).toBeVisible()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await expect(pinIndicator).not.toBeVisible()
await expect(pinIndicator).toBeHidden()
})
test('should allow toggling pin on multiple selected nodes with hotkey', async ({
@@ -43,8 +43,8 @@ test.describe('Vue Node Pin', () => {
await expect(pinIndicator2).toBeVisible()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await expect(pinIndicator1).not.toBeVisible()
await expect(pinIndicator2).not.toBeVisible()
await expect(pinIndicator1).toBeHidden()
await expect(pinIndicator2).toBeHidden()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {

View File

@@ -43,12 +43,8 @@ test.describe('Advanced Widget Visibility', () => {
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
await expect(node.getByLabel('max_shift', { exact: true })).toBeHidden()
await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
@@ -97,6 +93,6 @@ test.describe('Advanced Widget Visibility', () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
await expect(node.getByText('Show advanced inputs')).toBeHidden()
})
})

View File

@@ -17,7 +17,7 @@ test.describe('Vue Upload Widgets', () => {
await expect(
comfyPage.page.getByText('choose file to upload', { exact: true })
).not.toBeVisible()
).toBeHidden()
await expect
.poll(() =>

View File

@@ -46,13 +46,14 @@ test.describe('Vue Multiline String Widget', () => {
await expect(textarea).toHaveValue('Keep me around')
})
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
await textarea.focus()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).not.toBeVisible()
await expect(vueContextMenu).toBeHidden()
await textarea.blur()
await textarea.click({ button: 'right' })

View File

@@ -9,6 +9,7 @@ test.describe('Vue Widget Reactivity', () => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
@@ -32,6 +33,7 @@ test.describe('Vue Widget Reactivity', () => {
})
await expect(loadCheckpointNode).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-3"] > .lg-node-widgets > div'

View File

@@ -272,6 +272,7 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
// Expect the filename combo value to be updated
await expect.poll(() => fileComboWidget.getValue()).toBe('image32x32.webp')
})
test('Displays buttons when viewing single image of batch', async ({
comfyPage
}) => {

View File

@@ -83,7 +83,7 @@ test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
1,
'Unsaved Workflow (2)'
)
await expect(thumbnailImg).not.toBeVisible()
await expect(thumbnailImg).toBeHidden()
})
async function addNode(comfyPage: ComfyPage, category: string, node: string) {

View File

@@ -135,6 +135,6 @@ test.describe('Zoom Controls', { tag: '@canvas' }, () => {
await zoomButton.click()
await comfyPage.nextFrame()
await expect(zoomToFit).not.toBeVisible()
await expect(zoomToFit).toBeHidden()
})
})

View File

@@ -23,7 +23,7 @@ See `docs/testing/*.md` for detailed patterns.
## Component Testing
- Use Vue Test Utils for component tests
- Use `@testing-library/vue` with `@testing-library/user-event` for component tests (an ESLint rule bans `@vue/test-utils` in new tests)
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes

View File

@@ -31,7 +31,7 @@ Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (legacy; new tests must use @testing-library/vue)
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started

View File

@@ -1,5 +1,7 @@
# Component Testing Guide
> **Note**: New component tests must use `@testing-library/vue` with `@testing-library/user-event`. The examples below that use `@vue/test-utils` (`mount`, `wrapper`) are from legacy tests. An ESLint rule enforces this — importing from `@vue/test-utils` in `*.test.ts` files produces a lint error.
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
## Table of Contents

View File

@@ -432,6 +432,23 @@ export default defineConfig([
]
}
},
{
files: ['**/*.test.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@vue/test-utils',
message:
'Use @testing-library/vue with @testing-library/user-event instead.'
}
]
}
]
}
},
// Browser tests must use comfyPageFixture, not raw @playwright/test test
{
files: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.1",
"version": "1.44.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -28,15 +28,15 @@
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
"lint:fix:no-cache": "oxlint src browser_tests --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src browser_tests --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src browser_tests --type-aware && eslint src",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
"lint": "pnpm stylelint && oxlint src browser_tests --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"oxlint": "oxlint src browser_tests --type-aware",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
@@ -150,7 +150,6 @@
"@vitejs/plugin-vue": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:",
"eslint": "catalog:",
@@ -159,6 +158,7 @@
"eslint-plugin-better-tailwindcss": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-playwright": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",

29
pnpm-lock.yaml generated
View File

@@ -171,9 +171,6 @@ catalogs:
'@vitest/ui':
specifier: ^4.0.16
version: 4.0.16
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
'@vueuse/core':
specifier: ^14.2.0
version: 14.2.0
@@ -222,6 +219,9 @@ catalogs:
eslint-plugin-oxlint:
specifier: 1.59.0
version: 1.59.0
eslint-plugin-playwright:
specifier: ^2.10.1
version: 2.10.1
eslint-plugin-storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -690,9 +690,6 @@ importers:
'@vitest/ui':
specifier: 'catalog:'
version: 4.0.16(vitest@4.0.16)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
'@webgpu/types':
specifier: 'catalog:'
version: 0.1.66
@@ -717,6 +714,9 @@ importers:
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0))
eslint-plugin-playwright:
specifier: 'catalog:'
version: 2.10.1(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-storybook:
specifier: 'catalog:'
version: 10.2.10(eslint@9.39.1(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
@@ -6144,6 +6144,12 @@ packages:
peerDependencies:
oxlint: ~1.59.0
eslint-plugin-playwright@2.10.1:
resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==}
engines: {node: '>=16.9.0'}
peerDependencies:
eslint: '>=8.40.0'
eslint-plugin-storybook@10.2.10:
resolution: {integrity: sha512-aWkoh2rhTaEsMA4yB1iVIcISM5wb0uffp09ZqhwpoD4GAngCs131uq6un+QdnOMc7vXyAnBBfsuhtOj8WwCUgw==}
peerDependencies:
@@ -6563,6 +6569,10 @@ packages:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'}
globals@17.4.0:
resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
engines: {node: '>=18'}
globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
@@ -15581,6 +15591,11 @@ snapshots:
jsonc-parser: 3.3.1
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
eslint-plugin-playwright@2.10.1(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
globals: 17.4.0
eslint-plugin-storybook@10.2.10(eslint@9.39.1(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
@@ -16092,6 +16107,8 @@ snapshots:
globals@16.5.0: {}
globals@17.4.0: {}
globalthis@1.0.4:
dependencies:
define-properties: 1.2.1

View File

@@ -31,12 +31,12 @@ catalog:
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@tanstack/vue-virtual': ^3.13.12
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
'@testing-library/vue': ^8.1.0
@@ -58,7 +58,6 @@ catalog:
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
@@ -75,6 +74,7 @@ catalog:
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.59.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -6,8 +6,11 @@ import RangeEditor from './RangeEditor.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
function renderEditor(props: {
modelValue: { min: number; max: number; midpoint?: number }
[key: string]: unknown
}) {
return render(RangeEditor, {
props,
global: { plugins: [i18n] }
})
@@ -15,20 +18,19 @@ function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
describe('RangeEditor', () => {
it('renders with min and max handles', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
expect(screen.getByTestId('handle-min')).toBeDefined()
expect(screen.getByTestId('handle-max')).toBeDefined()
})
it('highlights selected range in plain mode', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
const highlight = screen.getByTestId('range-highlight')
expect(highlight.getAttribute('x')).toBe('0.2')
expect(
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
Number.parseFloat(highlight.getAttribute('width') ?? 'NaN')
).toBeCloseTo(0.6, 6)
})
@@ -37,37 +39,37 @@ describe('RangeEditor', () => {
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})
const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
const left = screen.getByTestId('range-dim-left')
const right = screen.getByTestId('range-dim-right')
expect(left.getAttribute('width')).toBe('0.2')
expect(right.getAttribute('x')).toBe('0.8')
})
it('hides midpoint handle by default', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
expect(screen.queryByTestId('handle-midpoint')).toBeNull()
})
it('shows midpoint handle when showMidpoint is true', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
expect(screen.getByTestId('handle-midpoint')).toBeDefined()
})
it('renders gradient background when display is gradient', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
@@ -76,8 +78,8 @@ describe('RangeEditor', () => {
]
})
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
expect(screen.getByTestId('gradient-bg')).toBeDefined()
expect(screen.getByTestId('gradient-def')).toBeDefined()
})
it('renders histogram path when display is histogram with data', () => {
@@ -85,47 +87,43 @@ describe('RangeEditor', () => {
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
expect(screen.getByTestId('histogram-path')).toBeDefined()
})
it('renders inputs for min and max', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const inputs = wrapper.findAll('input')
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(2)
})
it('renders midpoint input when showMidpoint is true', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
const inputs = wrapper.findAll('input')
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(3)
})
it('normalizes handle positions with custom value range', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})
const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')
const minHandle = screen.getByTestId('handle-min')
const maxHandle = screen.getByTestId('handle-max')
expect(
Number.parseFloat((minHandle.element as HTMLElement).style.left)
).toBeCloseTo(25, 0)
expect(
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
).toBeCloseTo(75, 0)
expect(Number.parseFloat(minHandle.style.left)).toBeCloseTo(25, 0)
expect(Number.parseFloat(maxHandle.style.left)).toBeCloseTo(75, 0)
})
})

View File

@@ -17,7 +17,14 @@
"
>
<defs v-if="display === 'gradient'">
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<linearGradient
:id="gradientId"
data-testid="gradient-def"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop
v-for="(stop, i) in computedStops"
:key="i"

View File

@@ -0,0 +1,138 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const settingMocks = vi.hoisted(() => ({
disableToast: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Toast.DisableReconnectingToast')
return settingMocks.disableToast
return undefined
})
}))
}))
describe('useReconnectingNotification', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.clearAllMocks()
settingMocks.disableToast = false
})
afterEach(() => {
vi.useRealTimers()
})
it('does not show toast immediately on reconnecting', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('shows error toast after delay', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
})
it('suppresses toast when reconnected before delay expires', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(500)
onReconnected()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('removes toast and shows success when reconnected after delay', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
mockToastAdd.mockClear()
onReconnected()
expect(mockToastRemove).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'g.reconnected',
life: 2000
})
)
})
it('does nothing when toast is disabled via setting', () => {
settingMocks.disableToast = true
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('does nothing when onReconnected is called without prior onReconnecting', () => {
const { onReconnected } = useReconnectingNotification()
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('handles multiple reconnecting events without duplicating toasts', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500) // first toast fires
onReconnecting() // second reconnecting event
vi.advanceTimersByTime(1500) // second toast fires
expect(mockToastAdd).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,52 @@
import { useTimeoutFn } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const RECONNECT_TOAST_DELAY_MS = 1500
export function useReconnectingNotification() {
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('g.reconnecting')
}
const reconnectingToastShown = ref(false)
const { start, stop } = useTimeoutFn(
() => {
toast.add(reconnectingMessage)
reconnectingToastShown.value = true
},
RECONNECT_TOAST_DELAY_MS,
{ immediate: false }
)
function onReconnecting() {
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
start()
}
function onReconnected() {
stop()
if (reconnectingToastShown.value) {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('g.reconnected'),
life: 2000
})
reconnectingToastShown.value = false
}
}
return { onReconnecting, onReconnected }
}

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
@@ -17,8 +17,8 @@ const i18n = createI18n({
}
})
function mountRoleBadge(role: 'owner' | 'member') {
return mount(RoleBadge, {
function renderRoleBadge(role: 'owner' | 'member') {
return render(RoleBadge, {
props: { role },
global: { plugins: [i18n] }
})
@@ -26,12 +26,12 @@ function mountRoleBadge(role: 'owner' | 'member') {
describe('RoleBadge', () => {
it('renders the owner label', () => {
const wrapper = mountRoleBadge('owner')
expect(wrapper.text()).toBe('Owner')
renderRoleBadge('owner')
expect(screen.getByText('Owner')).toBeInTheDocument()
})
it('renders the member label', () => {
const wrapper = mountRoleBadge('member')
expect(wrapper.text()).toBe('Member')
renderRoleBadge('member')
expect(screen.getByText('Member')).toBeInTheDocument()
})
})

View File

@@ -1,13 +1,19 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import LinearWelcome from './LinearWelcome.vue'
const hasNodes = ref(false)
const hasOutputs = ref(false)
const enterBuilder = vi.fn()
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
hasNodes: ref(false),
hasOutputs: ref(false),
enterBuilder: vi.fn()
}
})
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: vi.fn() })
@@ -33,12 +39,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function mountComponent(
function renderComponent(
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
) {
hasNodes.value = opts.hasNodes ?? false
hasOutputs.value = opts.hasOutputs ?? false
return mount(LinearWelcome, {
return render(LinearWelcome, {
global: { plugins: [i18n] }
})
}
@@ -51,30 +57,27 @@ describe('LinearWelcome', () => {
})
it('shows empty workflow text when there are no nodes', () => {
const wrapper = mountComponent({ hasNodes: false })
renderComponent({ hasNodes: false })
expect(
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(true)
screen.getByTestId('linear-welcome-empty-workflow')
).toBeInTheDocument()
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(false)
screen.queryByTestId('linear-welcome-build-app')
).not.toBeInTheDocument()
})
it('shows build app button when there are nodes but no outputs', () => {
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
renderComponent({ hasNodes: true, hasOutputs: false })
expect(
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(false)
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(true)
screen.queryByTestId('linear-welcome-empty-workflow')
).not.toBeInTheDocument()
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
})
it('clicking build app button calls enterBuilder', async () => {
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
await wrapper
.find('[data-testid="linear-welcome-build-app"]')
.trigger('click')
const user = userEvent.setup()
renderComponent({ hasNodes: true, hasOutputs: false })
await user.click(screen.getByTestId('linear-welcome-build-app'))
expect(enterBuilder).toHaveBeenCalled()
})
})

View File

@@ -1,9 +1,7 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +11,6 @@ import type {
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -98,35 +95,6 @@ describe('NodeWidgets', () => {
})
}
function mountComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return mount(NodeWidgets, {
props: { nodeData },
global: {
plugins: [pinia],
stubs: { InputSlot: true },
mocks: { $t: (key: string) => key }
}
})
}
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
fromAny<{ processedWidgets: unknown[] }, unknown>(
wrapper.vm
).processedWidgets.map(
(entry) =>
(
entry as {
simplified: {
borderStyle?: string
}
}
).simplified.borderStyle
)
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
@@ -155,19 +123,6 @@ describe('NodeWidgets', () => {
expect(stub).not.toBeNull()
expect(stub!.getAttribute('data-node-type')).toBe('')
})
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
'passes correct node type: %s',
(nodeType) => {
const widget = createMockWidget()
const nodeData = createMockNodeData(nodeType, [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
expect(stub!.getAttribute('data-node-type')).toBe(nodeType)
}
)
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
@@ -318,54 +273,6 @@ describe('NodeWidgets', () => {
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
false
)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({

View File

@@ -80,56 +80,16 @@
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import { onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { cn } from '@/utils/tailwindUtil'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
@@ -141,12 +101,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectInputsMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -160,8 +115,6 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -173,314 +126,11 @@ onErrorCaptured((error) => {
return false
})
const canSelectInputs = computed(
() =>
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()
const showAdvanced = computed(
() =>
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: string,
widgetOptions: IWidgetOptions | Record<string, never>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
}
}
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: string,
nodeErrors: { errors: { extra_info?: { input_name?: string } }[] } | undefined
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
)
)
}
function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
function isWidgetVisible(options: IWidgetOptions): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced.value)
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
// nodeData.id is the local node ID; subgraph nodes need the full execution
// path (e.g. "65:63") to match keys in lastNodeErrors.
const nodeExecId = app.isGraphReady
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
: String(nodeData.id ?? '')
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with disabled override for
// slot-linked widgets or widgets with disabled state (e.g. display-only)
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
updateHandler,
tooltipConfig,
slotMetadata
})
}
return result
})
const gridTemplateRows = computed((): string => {
// Use processedWidgets directly since it already has store-based hidden/advanced
return toValue(processedWidgets)
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
})
const {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
</script>

Some files were not shown because too many files have changed in this diff Show More