mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-06 07:51:57 +00:00
*PR Created by the Glary-Bot Agent*
---
## Summary
The node-canvas COLOR widget (`WidgetColorPicker.vue`) already migrated
off PrimeVue; this PR finishes FE-804 by porting the two remaining
`primevue/colorpicker` consumers — `FormColorPicker.vue` (settings form
`type: 'color'`) and `ColorCustomizationSelector.vue`
(folder-customization dialog) — to the in-house Reka-UI based
`ColorPicker`. The now-dead PrimeVue overlay workaround in
`CustomizationDialog.vue` is removed.
After this lands there are **zero `primevue/colorpicker` imports left**
in `src/`.
## Changes
- **What**: `FormColorPicker.vue` swaps `primevue/colorpicker` +
`primevue/inputtext` for the in-house `ColorPicker` + `Input`. The
legacy "hex without `#`" storage contract (e.g. `load3d`'s
`BackgroundColor` default `'282828'`) is preserved on read and on write.
- **What**: The text input now uses a separate draft value and only
commits on blur / Enter when the input is a complete 6- or 8-digit hex.
This fixes the "type `#f` and watch it snap to black" regression that a
naive shared-`v-model` implementation re-introduces.
- **What**: `disabled`, `id`, and `aria-labelledby` are now explicit
props on `FormColorPicker` and are forwarded to both children. The
custom `ColorPicker` learned a `disabled` prop that propagates to its
`<PopoverTrigger>` button.
- **What**: `ColorCustomizationSelector.vue` swaps
`primevue/colorpicker` for the in-house `ColorPicker` (still uses
`primevue/selectbutton` — intentionally out of scope per FE-804's title;
`SelectButton` migration is a separate effort).
- **What**: `CustomizationDialog.vue` drops the `.p-colorpicker-panel,
.p-overlay, .p-overlay-mask` `pointer-down-outside` guard. With PrimeVue
ColorPicker gone, no descendant of this dialog teleports an overlay to
`<body>` anymore.
- **What**: Updates two affected browser tests — `extensionAPI.spec.ts`
(the `disabled` attr smoke check) and `sidebar/nodeLibrary.spec.ts` (the
bookmark color customization flow) — to target the new picker via stable
accessible names (`role="slider"` + i18n aria-label `Color saturation
and brightness`) and the `.color-picker-wrapper > button` trigger. The
disabled-attr eval helper now handles `HTMLButtonElement` in addition to
`HTMLInputElement`.
- **What**: Adds `FormColorPicker.test.ts` with focused regression
coverage for the manual-entry contract: legacy no-`#` storage
round-trip, no commit on partial hex, revert on partial-then-Enter,
8-digit alpha hex, and `disabled` propagation.
- **Dependencies**: none added; removes two PrimeVue imports.
- **Breaking**: no breaking change to the documented FormItem `'color'`
setting contract. Manual-entry semantics change: typing partial hex no
longer immediately writes mangled state — it commits on blur or Enter
when the value fully parses. Existing settings values are unaffected.
## Verification
- `pnpm typecheck` clean
- `pnpm typecheck:browser` clean
- `pnpm exec eslint` on every touched file clean
- `pnpm test:unit` over the affected directories — **216 passed**
- Manual QA via Playwright against the running dev server:
- Registered a test extension with `type: 'color'` + a `disabled: true`
variant
- Confirmed the new picker renders, opens its Reka popover, and the
disabled row has `button.disabled === true` and `input.disabled ===
true`
- Confirmed partial hex (`#ab`) does **not** clobber the swatch
- Confirmed `#1133aa` + blur commits and round-trips through the picker
## Review focus
1. The manual-entry commit gate in `FormColorPicker.vue`
(`commitDraft()` + `FULL_HEX`) — is the regex strict enough? Should
3/4-digit shorthand hex be accepted on commit too? PrimeVue accepted
3-digit shorthand; the existing `toHexFromFormat()` already does, so
adding `|[0-9a-f]{3}|[0-9a-f]{4}` is a one-line change if reviewers want
parity.
2. Disabled-attr E2E selector swap (`.p-colorpicker-preview` →
`.color-picker-wrapper > button`) + the eval-helper update that now
handles `HTMLButtonElement` in addition to `HTMLInputElement`. The
structural selector matches what PrimeVue had; happy to add a
`data-testid` if reviewers prefer.
3. `ColorPicker.vue` gained a `disabled` prop — kept explicit (peer of
`class`) to match the existing prop shape rather than forwarding through
`$attrs`.
## Follow-up (NOT in this PR)
Discussed in-thread — the **Reka-UI `ColorField` migration** (full
picker rebuild) and the **Kijai regression suite** for alpha-disabled +
manual-entry on the node-canvas COLOR widget belong in a separate,
scoped PR alongside the `ColorInputSpec` schema additions (`hasAlpha`,
`format`). The custom picker also has a known lossy HSV-percent
quantization (e.g. `#1133aa` round-trips to `#1033a9`) that pre-dates
this PR and would be addressed by the Reka primitives.
- Fixes FE-804
## Screenshots




---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
|
|
import type { Settings } from '@/schemas/apiSchema'
|
|
import type { SettingParams } from '@/platform/settings/types'
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
|
|
/**
|
|
* Type helper for test settings with arbitrary IDs.
|
|
* Extensions can register settings with any ID, but SettingParams.id
|
|
* is typed as keyof Settings for autocomplete. This helper allows
|
|
* arbitrary IDs in tests while keeping type safety for other fields.
|
|
*/
|
|
type TestSettingId = keyof Settings
|
|
|
|
test.describe('Topbar commands', () => {
|
|
test('Should allow registering topbar commands', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
commands: [
|
|
{
|
|
id: 'foo',
|
|
label: 'foo-command',
|
|
function: () => {
|
|
window.foo = true
|
|
}
|
|
}
|
|
],
|
|
menuCommands: [
|
|
{
|
|
path: ['ext'],
|
|
commands: ['foo']
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
|
await expect
|
|
.poll(() => comfyPage.page.evaluate(() => window.foo))
|
|
.toBe(true)
|
|
})
|
|
|
|
test('Should not allow register command defined in other extension', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.command.registerCommand('foo', () => alert(1))
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
menuCommands: [
|
|
{
|
|
path: ['ext'],
|
|
commands: ['foo']
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
|
|
await expect(menuItem).toHaveCount(0)
|
|
})
|
|
|
|
test('Should allow registering keybindings', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
const app = window.app!
|
|
app.registerExtension({
|
|
name: 'TestExtension1',
|
|
commands: [
|
|
{
|
|
id: 'TestCommand',
|
|
function: () => {
|
|
window.TestCommand = true
|
|
}
|
|
}
|
|
],
|
|
keybindings: [
|
|
{
|
|
combo: { key: 'k' },
|
|
commandId: 'TestCommand'
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
await comfyPage.page.keyboard.press('k')
|
|
await expect
|
|
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
|
|
.toBe(true)
|
|
})
|
|
|
|
test.describe('Settings', () => {
|
|
test('Should allow adding settings', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
settings: [
|
|
{
|
|
// Extensions can register arbitrary setting IDs
|
|
id: 'TestSetting' as TestSettingId,
|
|
name: 'Test Setting',
|
|
type: 'text',
|
|
defaultValue: 'Hello, world!',
|
|
onChange: () => {
|
|
window.changeCount = (window.changeCount ?? 0) + 1
|
|
}
|
|
}
|
|
]
|
|
})
|
|
})
|
|
// onChange is called when the setting is first added
|
|
await expect
|
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
|
.toBe(1)
|
|
await expect
|
|
.poll(() => comfyPage.settings.getSetting('TestSetting'))
|
|
.toBe('Hello, world!')
|
|
|
|
await comfyPage.settings.setSetting('TestSetting', 'Hello, universe!')
|
|
await expect
|
|
.poll(() => comfyPage.settings.getSetting('TestSetting'))
|
|
.toBe('Hello, universe!')
|
|
await expect
|
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
|
.toBe(2)
|
|
})
|
|
|
|
test('Should allow setting boolean settings', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
settings: [
|
|
{
|
|
// Extensions can register arbitrary setting IDs
|
|
id: 'Comfy.TestSetting' as TestSettingId,
|
|
name: 'Test Setting',
|
|
type: 'boolean',
|
|
defaultValue: false,
|
|
onChange: () => {
|
|
window.changeCount = (window.changeCount ?? 0) + 1
|
|
}
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
await expect
|
|
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
|
|
.toBe(false)
|
|
await expect
|
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
|
.toBe(1)
|
|
|
|
await comfyPage.settingDialog.open()
|
|
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
|
|
await expect
|
|
.poll(() => comfyPage.settings.getSetting('Comfy.TestSetting'))
|
|
.toBe(true)
|
|
await expect
|
|
.poll(() => comfyPage.page.evaluate(() => window.changeCount))
|
|
.toBe(2)
|
|
})
|
|
|
|
test.describe('Passing through attrs to setting components', () => {
|
|
const testCases: Array<{
|
|
config: Pick<SettingParams, 'type' | 'defaultValue'> &
|
|
Partial<Omit<SettingParams, 'id' | 'type' | 'defaultValue'>>
|
|
selector: string
|
|
}> = [
|
|
{
|
|
config: {
|
|
type: 'boolean',
|
|
defaultValue: true
|
|
},
|
|
selector: '.p-toggleswitch.p-component'
|
|
},
|
|
{
|
|
config: {
|
|
type: 'number',
|
|
defaultValue: 10
|
|
},
|
|
selector: '.p-inputnumber input'
|
|
},
|
|
{
|
|
config: {
|
|
type: 'slider',
|
|
defaultValue: 10
|
|
},
|
|
selector: '.p-slider.p-component'
|
|
},
|
|
{
|
|
config: {
|
|
type: 'combo',
|
|
defaultValue: 'foo',
|
|
options: ['foo', 'bar', 'baz']
|
|
},
|
|
selector: '.p-select.p-component'
|
|
},
|
|
{
|
|
config: {
|
|
type: 'text',
|
|
defaultValue: 'Hello'
|
|
},
|
|
selector: '.p-inputtext'
|
|
},
|
|
{
|
|
config: {
|
|
type: 'color',
|
|
defaultValue: '#000000'
|
|
},
|
|
selector: '.color-picker-wrapper > button'
|
|
}
|
|
] as const
|
|
|
|
for (const { config, selector } of testCases) {
|
|
test(`${config.type} component should respect disabled attr`, async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.page.evaluate((config) => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
settings: [
|
|
{
|
|
// Extensions can register arbitrary setting IDs
|
|
id: 'Comfy.TestSetting' as TestSettingId,
|
|
name: 'Test',
|
|
attrs: { disabled: true },
|
|
...config
|
|
}
|
|
]
|
|
})
|
|
}, config)
|
|
|
|
await comfyPage.settingDialog.open()
|
|
const component = comfyPage.settingDialog.root
|
|
.getByText('TestSetting Test')
|
|
.locator(selector)
|
|
|
|
await expect
|
|
.poll(() =>
|
|
component.evaluate((el) =>
|
|
el instanceof HTMLInputElement ||
|
|
el instanceof HTMLButtonElement
|
|
? el.disabled
|
|
: el.classList.contains('p-disabled')
|
|
)
|
|
)
|
|
.toBe(true)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe('About panel', () => {
|
|
test('Should allow adding badges', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
aboutPageBadges: [
|
|
{
|
|
label: 'Test Badge',
|
|
url: 'https://example.com',
|
|
icon: 'pi pi-box'
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
await comfyPage.settingDialog.open()
|
|
await comfyPage.settingDialog.goToAboutPanel()
|
|
const badge = comfyPage.page.locator('.about-badge').last()
|
|
expect(badge).toBeDefined()
|
|
await expect(badge).toContainText('Test Badge')
|
|
})
|
|
})
|
|
|
|
test.describe('Dialog', () => {
|
|
test('Should allow showing a prompt dialog', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
void window
|
|
.app!.extensionManager.dialog.prompt({
|
|
title: 'Test Prompt',
|
|
message: 'Test Prompt Message'
|
|
})
|
|
.then((value: string | null) => {
|
|
;(window as unknown as Record<string, unknown>)['value'] = value
|
|
})
|
|
})
|
|
|
|
await comfyPage.nodeOps.fillPromptDialog('Hello, world!')
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(
|
|
() => (window as unknown as Record<string, unknown>)['value']
|
|
)
|
|
)
|
|
.toBe('Hello, world!')
|
|
})
|
|
|
|
test('Should allow showing a confirmation dialog', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
void window
|
|
.app!.extensionManager.dialog.confirm({
|
|
title: 'Test Confirm',
|
|
message: 'Test Confirm Message'
|
|
})
|
|
.then((value: boolean | null) => {
|
|
;(window as unknown as Record<string, unknown>)['value'] = value
|
|
})
|
|
})
|
|
|
|
await comfyPage.confirmDialog.click('confirm')
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(
|
|
() => (window as unknown as Record<string, unknown>)['value']
|
|
)
|
|
)
|
|
.toBe(true)
|
|
})
|
|
|
|
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => {
|
|
;(window as unknown as Record<string, unknown>)['value'] = 'foo'
|
|
void window
|
|
.app!.extensionManager.dialog.confirm({
|
|
title: 'Test Confirm',
|
|
message: 'Test Confirm Message'
|
|
})
|
|
.then((value: boolean | null) => {
|
|
;(window as unknown as Record<string, unknown>)['value'] = value
|
|
})
|
|
})
|
|
|
|
await comfyPage.confirmDialog.click('reject')
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(
|
|
() => (window as unknown as Record<string, unknown>)['value']
|
|
)
|
|
)
|
|
.toBeNull()
|
|
})
|
|
})
|
|
|
|
test.describe('Selection Toolbox', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
|
})
|
|
|
|
test('Should allow adding commands to selection toolbox', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Register an extension with a selection toolbox command
|
|
await comfyPage.page.evaluate(() => {
|
|
window.app!.registerExtension({
|
|
name: 'TestExtension1',
|
|
commands: [
|
|
{
|
|
id: 'test.selection.command',
|
|
label: 'Test Command',
|
|
icon: 'pi pi-star',
|
|
function: () => {
|
|
;(window as unknown as Record<string, unknown>)[
|
|
'selectionCommandExecuted'
|
|
] = true
|
|
}
|
|
}
|
|
],
|
|
getSelectionToolboxCommands: () => ['test.selection.command']
|
|
})
|
|
})
|
|
|
|
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
|
|
|
// Click the command button in the selection toolbox
|
|
const toolboxButton = comfyPage.page.locator(
|
|
'.selection-toolbox button:has(.pi-star)'
|
|
)
|
|
await toolboxButton.click()
|
|
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(
|
|
() =>
|
|
(window as unknown as Record<string, unknown>)[
|
|
'selectionCommandExecuted'
|
|
]
|
|
)
|
|
)
|
|
.toBe(true)
|
|
})
|
|
})
|
|
})
|